Compare commits

...
11 Commits
Author SHA1 Message Date
sneak 7feb212d04 Adopt the scripts-to-rule-them-all layout and the canonical Docker gates
check / check (push) Successful in 1m3s
script/ holds the entrypoints and the Makefile targets shim to them.
Linting and testing are phases of the Dockerfile, built alone by
script/lint and script/test with a fresh CACHEBUST value and cache
mounts for Go's build cache and golangci-lint's; the build stage
depends on both, and the runtime stage ships the binary and the source
without .git. Formatting stays on the host in script/fmt and
script/fmt-check (gofmt and the prettier pinned in yarn.lock).
script/cibuild bootstraps, runs script/check and builds the tagged
image, and the Gitea workflow calls it. script/precommit refuses a
commit whose go.mod or go.sum is not tidy.

cgo stays off, so the test phase runs on the alpine Go image without
-race; that is the one departure from the canonical Go Dockerfile.

Re-vendored from the prompts repository's next branch: REPO_POLICIES.md
(2026-11-04), .golangci.yml (byte-identical, so the local
exhaustruct_v5 addition is gone now that canonical carries it),
.gitea/workflows/check.yml, .editorconfig, .gitignore and .dockerignore,
the last two followed by this repository's own entries. AGENTS.md holds
the repository-specific guidance for agents. The README gains the
Entrypoints section and describes the new gates.
2026-11-04 07:31:59 -08:00
sneak 538eca9f5d Re-vendor .golangci.yml and REPO_POLICIES.md from the canonical source
The canonical config is already in the v2 layout and now enables
depguard with the test-support rule and a gomodguard_v2 block list;
this copy is byte-identical to it plus exhaustruct_v5 in the disable
list, which is what the pending upstream change adds. REPO_POLICIES.md
is refreshed from 2026-07-06 to 2026-09-08.
2026-11-01 16:31:32 -08:00
sneak 09ec70009d Verify .golangci.yml against the linter schema in make lint
golangci-lint ignores keys it does not know and silently runs with
defaults, which is how the v1-era settings went unapplied. make lint now
runs golangci-lint config verify before golangci-lint run, in both the
Docker and the in-image paths.
2026-11-01 16:31:30 -08:00
sneak e1b385f847 Satisfy the now-effective lll limit and the modernize linter 2026-10-31 13:22:44 -07:00
sneak 897dc9aced Update golangci-lint to v2.15.0 and fix the linter config
Convert .golangci.yml to the v2 layout: linters-settings and
issues.exclude-use-default were rejected by the v2 schema, so the lll,
funlen, cyclop, and dupl settings were never applied. Disable
exhaustruct_v5 (the newer major version of the already-disabled
exhaustruct) and the deprecated gomodguard. Pin the golangci-lint image
at v2.15.0 in the Makefile and Dockerfile.
2026-10-31 13:22:43 -07:00
sneak ad1d51a9f9 Drop the BIP39 passphrase from cbk
The seed is always computed with an empty passphrase; Params is just
the mnemonic and the BIP85 index.
2026-10-31 11:41:11 -07:00
sneak fe6dbba428 Derive cbk keys with BIP85 instead of an ad hoc HKDF label
The private key is now the BIP85 HEX application output for 32 bytes at
m/83696968'/128169'/32'/{index}' from the BIP32 master key of the BIP39
seed, via sneak.berlin/go/secret/pkg/bip85, so any BIP85 tool reproduces
it and one mnemonic backs many keypairs through Params.Index. Tests
check the mnemonic-to-master step and the HEX application against the
BIP85 specification vectors and pin the resulting keypair.
2026-10-31 11:38:02 -07:00
sneak ea6faaecff Narrow pkg/cbk to keypair derivation
Remove KeyFromHex, Seal, and Open with their errors and constants. The
library only derives crypto_box keypairs from BIP39 mnemonics; callers
use golang.org/x/crypto/nacl/box directly for everything else.
2026-10-31 11:27:09 -07:00
sneak 5932cc12ee Remove cbk and mnemonic commands from sutil
sutil holds only the utilities wanted on every machine; it is not a
command-line front end for the packages in pkg/. cbk and bip39wordlist
remain libraries with no CLI surface. The README states the boundary.
2026-10-31 10:16:10 -07:00
sneak 9def7ba586 Document config loading, cgo policy, and tagging policy in README 2026-10-31 10:11:49 -07:00
sneak 4d45aa4ca3 Restore config loading, keep exported Version, keep cgo off
Bring back the in-progress viper and go-homedir configuration loading
from the original root command as a persistent pre-run on the root
command, with a --config flag and a config line in sutil info. Restore
the exported main.Version with a nolint directive instead of renaming
it. Export CGO_ENABLED=0 from the Makefile, drop -race from make test,
and remove the C toolchain from the Docker builder stage.
2026-10-31 10:11:47 -07:00
33 changed files with 2248 additions and 841 deletions

No files matched your search

+79 -8
View File
@@ -1,9 +1,80 @@
.git # .dockerignore does NOT use .gitignore semantics. Docker matches with
node_modules # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
.DS_Store # `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own host-built artifacts, anchored.
/sutil /sutil
*.test **/*.test
*.out **/*.out
*.log **/*.log
*.prof **/*.prof
coverage.* **/coverage.*
+6
View File
@@ -10,3 +10,9 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
[*.go]
indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
+12 -1
View File
@@ -1,9 +1,20 @@
name: check name: check
on: [push] on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: docker build . # script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
+39 -7
View File
@@ -11,17 +11,49 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Node (prettier for markdown formatting) # Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/ node_modules/
# Environment / secrets # Secrets. Unanchored like every entry above, so each matches at every
.env # depth. Matching is case-sensitive on Linux, so names use character
.env.* # ranges rather than a lowercase form that misses `Server.Key`.
*.pem
*.key
# Go build and test artifacts # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation at the end of this file, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# The sutil binary that `make build` writes at the root.
/sutil /sutil
# Go test and profiling outputs.
*.test *.test
*.out *.out
*.log *.log
+105 -15
View File
@@ -1,32 +1,122 @@
version: "2" 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.
#
# This file has no formatters section, so golangci-lint run does not run
# gofmt. A repository's script/fmt-check runs it, on the host.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct_v5 # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
linters-settings: # silenced by disabling that name, not by enabling the successor.
lll: - exhaustruct # Deprecated, replaced by exhaustruct_v5
line-length: 88 - wsl # Deprecated, replaced by wsl_v5
funlen: - gomodguard # Deprecated, replaced by gomodguard_v2
lines: 80 settings:
statements: 50 lll:
cyclop: line-length: 88
max-complexity: 15 funlen:
dupl: lines: 80
threshold: 100 statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt. A package
# in a subdirectory of that directory is not exempt. `${base-path}` is
# the directory holding this file, so the name of the directory a
# repository is cloned into does not count.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!${base-path}**/*test/*"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Each entry names a module not to use and what to use instead. Every
# entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# These entries cover only the github.com/go-redis/redis module paths,
# one entry per path, because the later releases are separate paths.
# A prefix match would be shorter but would also reach
# github.com/go-redis/redismock/v9, the test double for the successor
# these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v9
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
exclusions:
rules:
# homoicon keeps a local copy of sobek, its JavaScript engine, as sneak
# decided in
# https://git.eeqj.de/sneak/homoicon/issues/1759#issuecomment-153261.
# This is an exclusion because gomoddirectives does not check its
# replace-allow-list for a replace that points at a local path.
- path: ^go\.mod$
linters:
- gomoddirectives
text: '^local replacement are not allowed: github\.com/grafana/sobek$'
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
uniq-by-line: false
+19
View File
@@ -0,0 +1,19 @@
# AGENTS.md
Guidance for coding agents working in this repository. The repository policies
are in `REPO_POLICIES.md`, vendored from the `prompts` repository; read that
first. Everything below is specific to this repository, and the README's Design
section explains each point.
- `pkg/` holds only packages designed to be imported by other programs. Anything
that exists to support `sutil` lives under `internal/`.
- `sutil` is not a command-line front end for the packages in `pkg/`. It holds
only the utilities the author wants available everywhere; a command is added
because a tool is wanted, not because a package exists.
- `pkg/cbk` derives crypto_box keypairs from BIP39 mnemonics with BIP85 and does
nothing else. BIP39 passphrases are deliberately unsupported.
- cgo stays off: builds are static, and the test phase runs without `-race`.
- Version tags are created by the author alone, when the author decides. Do not
propose, schedule, or create tags.
- `.golangci.yml` is byte-identical to the canonical copy and is not edited
here; changes go to the `prompts` repository.
+72 -37
View File
@@ -1,60 +1,95 @@
# The checks behind `make check` are split across independent stages so that # In every stage ARG CACHEBUST comes right after the steps that install
# formatting and lint failures surface in seconds, before any compilation: # system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# #
# lint - gofmt and golangci-lint (make fmt-check-go, make lint) # This repository keeps cgo off, so its test phase departs from the
# docs - prettier check of all markdown (make fmt-check-md) # canonical Go Dockerfile in REPO_POLICIES.md in one way: it runs on the
# builder - tests (make test) and the release build # alpine Go image without -race, which needs a C toolchain. Everything
# # else follows the canonical file.
# The builder stage declares explicit dependencies on lint and docs so
# BuildKit cannot finish the build unless every check passed.
# Lint stage: fast feedback on Go formatting and lint issues # Lint phase
# golangci/golangci-lint:v2.12.2, 2026-05-06 # golangci/golangci-lint:v2.15.0, 2026-10-16
# Pinned below v2.13.0 until the canonical .golangci.yml disables FROM golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481 AS lint
# exhaustruct_v5 (see the TODO in README.md). Keep in sync with Makefile.
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN make fmt-check-go RUN --mount=type=cache,target=/root/.cache/go-build \
RUN make lint --mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Docs stage: markdown formatting check with the pinned prettier # Test phase. -count=1 because the mounted cache also holds test
# node:24-alpine, 2026-10-28 # results, which go test would otherwise replay. A first run that timed
FROM node@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85 AS docs # out gets no rerun, because a rerun would only wait for the same hang.
RUN apk add --no-cache make # golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS test
ENV CGO_ENABLED=0
WORKDIR /src WORKDIR /src
COPY package.json yarn.lock ./ COPY go.mod go.sum ./
RUN yarn install --frozen-lockfile RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN make fmt-check-md RUN --mount=type=cache,target=/root/.cache/go-build \
status=0; \
output=$(go test -count=1 -timeout 90s -cover ./... 2>&1) || status=$?; \
printf '%s\n' "$output"; \
test "$status" -eq 0 && exit 0; \
case "$output" in \
*"panic: test timed out"*|*"*** Test killed"*) exit 1 ;; \
esac; \
echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -v ./...; \
exit 1
# Build stage # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# golang:1.26-alpine, 2026-10-27 # golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder
# gcc and musl-dev are needed only because `make test` uses -race, which # git comes from the base image's own distribution package index, which
# requires cgo; the release binary below is built with CGO_ENABLED=0. # is trusted, so it needs no version and no hash.
RUN apk add --no-cache make gcc musl-dev RUN apk add --no-cache git
WORKDIR /src WORKDIR /src
# Force BuildKit to run the lint and docs stages before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY --from=docs /src/package.json /dev/null
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . . COPY . .
RUN make test
ARG VERSION=dev # The VERSION build arg when one is given, otherwise
RUN CGO_ENABLED=0 go build -trimpath \ # `git describe --tags --always` on the .git in the build context. With
-ldflags="-s -w -X main.version=${VERSION}" \ # .git present, a version that is still empty, dev or unknown fails the
-o /sutil ./cmd/sutil # build: git is missing or could not read the checkout.
ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /sutil ./cmd/sutil
# The runtime stage ships the source; the repository history is not
# part of it.
RUN rm -rf /src/.git
# Runtime stage: the binary plus the full source it was built from # Runtime stage, and the last one: the binary plus the source it was
# built from.
# alpine:3.24, 2026-10-23 # alpine:3.24, 2026-10-23
FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG CACHEBUST
COPY --from=builder /sutil /usr/local/bin/sutil COPY --from=builder /sutil /usr/local/bin/sutil
COPY --from=builder /src /usr/local/src/x COPY --from=builder /src /usr/local/src/x
ENTRYPOINT ["sutil"] ENTRYPOINT ["sutil"]
+40 -90
View File
@@ -1,29 +1,46 @@
# sneak.berlin/go/x .PHONY: bootstrap setup test lint fmt fmt-check check docker hooks \
# build install tidy clean
# This Makefile is the single source of truth for how this repo is built,
# tested, linted, and formatted. Always use these targets rather than
# invoking the underlying tools directly.
.PHONY: all build install test lint fmt fmt-go fmt-md fmt-check \ # Makefile targets are thin shims; the implementations live in script/
fmt-check-go fmt-check-md check docker hooks tidy setup clean # per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md). The targets after the shims are this repo's own.
bootstrap:
@script/bootstrap
setup:
@script/setup
test:
@script/test
lint:
@script/lint
fmt:
@script/fmt
fmt-check:
@script/fmt-check
check:
@script/check
docker:
@script/docker
hooks:
@script/install-precommit
# This repository's own targets.
BINARY := sutil BINARY := sutil
VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
GOLDFLAGS := -X main.version=$(VERSION) GOLDFLAGS := -X main.Version=$(VERSION)
GO_DIRS := cmd internal pkg
# golangci/golangci-lint:v2.12.2, 2026-05-06 # cgo stays off everywhere: builds are static, and the test phase of the
# Pinned below v2.13.0 until the canonical .golangci.yml disables # Dockerfile runs without -race, which would need a C toolchain.
# exhaustruct_v5 (see the TODO in README.md). Keep in sync with Dockerfile. export CGO_ENABLED := 0
GOLANGCI_LINT_IMAGE := golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240
# flags are repeated here (also in .prettierrc) so this Makefile works
# standalone when copied as a template
PRETTIER := yarn run --silent prettier
PRETTIER_FLAGS := --tab-width 4 --prose-wrap always
MARKDOWN_GLOB := '**/*.md'
all: build
# Build the sutil binary in the repo root with the git version embedded. # Build the sutil binary in the repo root with the git version embedded.
build: build:
@@ -33,76 +50,9 @@ build:
install: install:
go install -trimpath -ldflags "$(GOLDFLAGS)" ./cmd/$(BINARY) go install -trimpath -ldflags "$(GOLDFLAGS)" ./cmd/$(BINARY)
# Run the tests quietly; on failure rerun verbosely for diagnostics and # Tidy go.mod and go.sum; script/precommit refuses a commit that needs it.
# still fail.
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
# Lint with the pinned golangci-lint image when docker is available so local
# results match CI exactly. Inside the Docker build (where there is no
# docker) the image's own golangci-lint binary is used. The host module
# cache is shared read-only; the linter and build caches live in named
# volumes so repeated runs are fast.
lint:
@if command -v docker >/dev/null 2>&1; then \
docker run --rm \
-v "$(CURDIR)":/src \
-v "$$(go env GOMODCACHE)":/go/pkg/mod:ro \
-v sutil-golangci-lint-cache:/root/.cache/golangci-lint \
-v sutil-go-build-cache:/root/.cache/go-build \
-w /src $(GOLANGCI_LINT_IMAGE) golangci-lint run ./...; \
else \
golangci-lint run ./...; \
fi
# Format everything (writes files).
fmt: fmt-go fmt-md
fmt-go:
gofmt -w $(GO_DIRS)
fmt-md:
$(PRETTIER) --write $(MARKDOWN_GLOB) $(PRETTIER_FLAGS)
# Check formatting without writing anything.
fmt-check: fmt-check-go fmt-check-md
fmt-check-go:
@unformatted="$$(gofmt -l $(GO_DIRS))"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt: the following files need formatting:"; \
echo "$$unformatted"; \
exit 1; \
fi
fmt-check-md:
$(PRETTIER) --check $(MARKDOWN_GLOB) $(PRETTIER_FLAGS)
# Everything CI gates on. Must not modify any files.
check: test lint fmt-check
# Build the Docker image; the Dockerfile runs the same checks as `make check`.
docker:
docker build -t $(BINARY) .
# Install the pre-commit hook.
hooks:
@printf '#!/bin/sh\nset -e\n' > .git/hooks/pre-commit
@if [ -f go.mod ]; then \
printf 'go mod tidy\ngo fmt ./...\ngit diff --exit-code -- go.mod go.sum || { echo "go mod tidy changed files; please stage and retry"; exit 1; }\n' >> .git/hooks/pre-commit; \
fi
@printf 'make check\n' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
# Tidy go.mod and go.sum.
tidy: tidy:
go mod tidy go mod tidy
# One-time developer setup: install the pinned prettier for markdown checks.
setup:
yarn install --frozen-lockfile
clean: clean:
rm -f $(BINARY) rm -f $(BINARY)
+109 -84
View File
@@ -4,8 +4,8 @@
`sneak.berlin/go/x` is a WTFPL-licensed Go module and CLI tool by `sneak.berlin/go/x` is a WTFPL-licensed Go module and CLI tool by
[@sneak](https://sneak.berlin) that serves as an umbrella publishing space for [@sneak](https://sneak.berlin) that serves as an umbrella publishing space for
small reusable packages, plus `sutil`, a command-line utility that exposes them small reusable packages, plus `sutil`, a command-line utility holding a few
from the shell. small tools worth having on every machine.
Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that
exists only to support it lives in `internal/`. exists only to support it lives in `internal/`.
@@ -39,18 +39,13 @@ sutil generate random --count 1073741824 "my seed" > testdata.bin
sutil generate password sutil generate password
# marble7-sprout2-velvet9 # marble7-sprout2-velvet9
# A new 24-word BIP39 mnemonic phrase (or --words 12). # Build metadata, including which config file (if any) was loaded.
sutil generate mnemonic
# A crypto_box keypair derived from a mnemonic. The mnemonic is read from
# stdin, never from the command line, so it stays out of shell history.
sutil generate mnemonic > mnemonic.txt
sutil cbk derive < mnemonic.txt
sutil cbk derive --public < mnemonic.txt
# Build metadata.
sutil version sutil version
sutil info sutil info
# Every command first loads ~/.sneak.yaml (or .json, .toml, ...) if it
# exists, or the file named with --config.
sutil --config ./sneak.yaml info
``` ```
### Use the packages ### Use the packages
@@ -71,30 +66,15 @@ func main() {
panic(err) panic(err)
} }
// The same mnemonic (and optional passphrase) always yields the same // The same mnemonic always yields the same keypair, on any machine.
// keypair, on any machine. // The keys are plain crypto_box keys for use with
alice, err := cbk.New(cbk.Params{Mnemonic: mnemonic}) // golang.org/x/crypto/nacl/box.
keypair, err := cbk.New(cbk.Params{Mnemonic: mnemonic})
if err != nil { if err != nil {
panic(err) panic(err)
} }
bob, err := cbk.FromMnemonic("abandon abandon abandon abandon abandon " + fmt.Println(keypair.PublicKeyHex())
"abandon abandon abandon abandon abandon abandon about")
if err != nil {
panic(err)
}
sealed, err := alice.Seal([]byte("hello bob"), bob.PublicKey)
if err != nil {
panic(err)
}
plain, err := bob.Open(sealed, alice.PublicKey)
if err != nil {
panic(err)
}
fmt.Println(string(plain), alice.PublicKeyHex())
} }
``` ```
@@ -138,37 +118,46 @@ of the module stays small and intentional.
The command tree holds no global state: `cli.New` builds every command from a The command tree holds no global state: `cli.New` builds every command from a
`Params` struct, so tests construct it repeatedly with captured input and `Params` struct, so tests construct it repeatedly with captured input and
output, and nothing happens in `init` functions. output, and nothing happens in `init` functions. Before any command runs, the
root command's persistent pre-run loads the optional configuration file (the one
named by `--config`, or else `.sneak.*` in the home directory found with
`go-homedir`) into a viper instance that also exposes environment variables. No
command consumes settings from it yet; `sutil info` reports which file was
loaded. Tests point `Params.ConfigDir` at a temporary directory so the real home
directory is never read.
### `pkg/cbk` ### `pkg/cbk`
Derives NaCl crypto_box keypairs (Curve25519, XSalsa20, Poly1305) Derives NaCl crypto_box keypairs (Curve25519) deterministically from BIP39
deterministically from BIP39 mnemonics, and seals and opens messages with them. mnemonics, and nothing else. The entropy comes from
The derivation is fixed and documented so keys are reproducible: [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki) through
`sneak.berlin/go/secret/pkg/bip85`, so any BIP85-capable tool given the same
mnemonic reproduces the same key, and one mnemonic can back any number of
independent keypairs:
1. The mnemonic is validated and canonicalized with 1. The mnemonic is validated and canonicalized with
`bip39wordlist.NormalizeWordlist`. `bip39wordlist.NormalizeWordlist`.
2. The standard 64-byte BIP39 seed is computed with PBKDF2-HMAC-SHA512 (2048 2. The standard 64-byte BIP39 seed is computed with PBKDF2-HMAC-SHA512 (2048
rounds, salt `"mnemonic" + passphrase`), exactly as a BIP39 wallet would, so rounds, salt `"mnemonic"`), exactly as a BIP39 wallet would with an empty
the optional passphrase works as the usual "25th word". passphrase. BIP39 passphrases are deliberately not supported.
3. The 32-byte private key is 3. The seed becomes a BIP32 master key.
`HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`. 4. BIP85's HEX application derives 32 bytes of entropy at
4. The public key is the Curve25519 base-point multiple of the private key. `m/83696968'/128169'/32'/{index}'`; those bytes are the private key.
5. The public key is the Curve25519 base-point multiple of the private key.
A sealed message is a 24-byte random nonce followed by the box ciphertext, so it The resulting keys are plain 32-byte crypto_box keys for use with
can be handled as a single opaque byte string. The API: `golang.org/x/crypto/nacl/box`. The API:
- `cbk.New(cbk.Params{Mnemonic, Passphrase})` and `cbk.FromMnemonic(m)` return a - `cbk.New(cbk.Params{Mnemonic, Index})` and `cbk.FromMnemonic(m)` (index 0)
`CryptoBoxKeypair`. return a `CryptoBoxKeypair`.
- `CryptoBoxKeypair.Seal(message, recipientPublicKey)` and - `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, and the `PublicKeyHex`,
`CryptoBoxKeypair.Open(sealed, senderPublicKey)`. `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors on the keypair.
- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, `cbk.KeyFromHex(s)`, and the
`PublicKeyHex`, `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors
on the keypair.
The test suite pins the keypair derived from the standard all-zero-entropy BIP39 The test suite checks the mnemonic-to-master-key step and the BIP85 HEX
test vector. If that test ever fails, the derivation has changed and existing application against the BIP85 specification's own test vectors, verifies that
keys are no longer reproducible. the private key is exactly the BIP85 output, and pins the keypair for the
specification's mnemonic at index 0. If the pinned test ever fails, the
derivation has changed and existing keys are no longer reproducible.
### `pkg/bip39wordlist` ### `pkg/bip39wordlist`
@@ -218,53 +207,89 @@ sutil
├── info print version, Go version, module, VCS metadata ├── info print version, Go version, module, VCS metadata
├── generate (gen) ├── generate (gen)
│ ├── random [seedstr] stream pseudorandom bytes (--count N to stop) │ ├── random [seedstr] stream pseudorandom bytes (--count N to stop)
│ ├── password (pw) print a memorable password │ └── password (pw) print a memorable password
│ └── mnemonic print a new BIP39 mnemonic (--words N)
└── cbk
└── derive keypair from a mnemonic on stdin (--public)
``` ```
`sutil` is not a command-line front end for the packages in `pkg/`. It holds
only the handful of utilities the author wants available everywhere. `pkg/cbk`
and `pkg/bip39wordlist` are libraries with no CLI surface, and a command is
added because a tool is wanted, not because a package exists.
### Build, test, and CI ### Build, test, and CI
The `Makefile` is the single entry point: `make test` (30-second timeout, race The gates live in the `Dockerfile`, in the canonical Go layout that
detector, quiet unless something fails), `make lint`, `make fmt` and `REPO_POLICIES.md` prescribes: a `lint` phase on the hash-pinned golangci-lint
`make fmt-check` (gofmt for Go, prettier for markdown), `make check` for all image and a `test` phase on the Go image. `script/lint` and `script/test` each
three, `make build`, `make install`, `make docker`, `make hooks`, `make tidy`, build one phase alone, with a fresh `CACHEBUST` value so the check step runs on
and `make setup` to install the pinned prettier. every build instead of being served from Docker's cache, and write no image. The
build stage depends on both phases, so the image cannot be built unless they
pass. Go's build cache and golangci-lint's cache are mounted into the phases and
survive between builds. Formatting is the one check that runs on the host:
`script/fmt-check` runs gofmt and the prettier pinned in `yarn.lock`.
`script/check` runs all three. `script/cibuild`, which the Gitea workflow calls,
bootstraps, runs `script/check`, and then builds the tagged image with the
version from `git describe`.
`make lint` runs golangci-lint inside the same hash-pinned Docker image the CI
build uses, so local results match CI exactly. The `Dockerfile` splits
`make check` across stages: a lint stage and a markdown-check stage fail fast in
seconds, and the build stage, which runs the tests and compiles `sutil`, depends
on both. The runtime image carries the binary and the source it was built from.
Every external reference, from base images to the GitHub Action in Every external reference, from base images to the GitHub Action in
`.gitea/workflows/check.yml`, is pinned by content hash with a version and date `.gitea/workflows/check.yml`, is pinned by content hash with a version and date
comment. `.golangci.yml` is the standard configuration and is never edited by comment. `.golangci.yml` is byte-identical to the canonical copy in the
hand here. `prompts` repository and is never edited here; the lint phase pins the
golangci-lint release that the policy names. cgo stays off: the Makefile exports
`CGO_ENABLED=0`, binaries are static, and the test phase runs on the alpine Go
image without `-race`, which is this repository's one departure from the
canonical `Dockerfile`. The runtime image carries the binary and the source it
was built from.
Version tags are created by the author alone, when the author decides. Tooling,
contributors, and agents working in this repository do not propose, schedule, or
create tags.
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap`: install everything the repository needs, idempotently:
make, git, go, and, because `yarn.lock` is present, node, yarn 1 and the
pinned prettier.
- `script/setup`: make a fresh clone ready for development by running
`script/bootstrap` and then `script/install-precommit`.
- `script/projectname`: print the project name, `x`, which `script/docker` and
`script/cibuild` use as the image tag.
- `script/test`: build the `test` phase of the `Dockerfile` with a new
`CACHEBUST` value, writing no image.
- `script/lint`: build the `lint` phase the same way.
- `script/fmt`: gofmt and prettier, writing files.
- `script/fmt-check`: the read-only twin of `script/fmt`.
- `script/check`: `script/test`, `script/lint`, then `script/fmt-check`. It
modifies nothing.
- `script/docker`: build the image tagged `x`, passing the version from
`git describe`.
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
tagged image build.
- `script/precommit`: run by the pre-commit hook. It refuses a commit whose
`go.mod` or `go.sum` is not tidy, then runs `script/check`.
- `script/install-precommit`: install that hook; `make hooks` shims to it.
The Makefile adds four targets of its own: `make build`, `make install`,
`make tidy`, and `make clean`.
## Development ## Development
```sh ```sh
make setup # once: install the pinned prettier with yarn make setup # once: bootstrap and install the pre-commit hook
make check # test + lint + fmt-check, exactly what CI gates on make check # test, lint, fmt-check: what the hook and CI run
make fmt # gofmt and prettier, writes files make fmt # gofmt and prettier, writes files
make hooks # install the pre-commit hook that runs make check make build # build ./sutil on the host
make docker # the full CI build make docker # build the image tagged x
``` ```
## TODO ## TODO
- [ ] Tag `v0.1.0` once `main` is green in CI. Nothing outstanding.
- [ ] Bump the pinned `golangci/golangci-lint` image (in `Makefile` and
`Dockerfile`) past v2.12.2 once the canonical `.golangci.yml` disables
`exhaustruct_v5`, which v2.13.0 added alongside the already-disabled
`exhaustruct`, and replaces the deprecated `gomodguard` with
`gomodguard_v2`. With `default: all`, newer images enable `exhaustruct_v5`
and fail every partial struct literal.
- [ ] Add `sutil cbk seal` and `sutil cbk open` so messages can be sealed and
opened from the shell, not just from Go.
- [ ] Let `sutil cbk derive` take the BIP39 passphrase from an environment
variable; the library supports it, the CLI does not expose it yet.
## License ## License
+877 -138
View File
File diff suppressed because it is too large. Load diff
+8 -6
View File
@@ -1,5 +1,5 @@
// Command sutil is sneak's shell utility: small tools backed by the // Command sutil is sneak's shell utility: a few small tools worth having on
// packages in sneak.berlin/go/x. Run `sutil --help` for the command list. // every machine. Run `sutil --help` for the command list.
package main package main
import ( import (
@@ -8,12 +8,14 @@ import (
"sneak.berlin/go/x/internal/cli" "sneak.berlin/go/x/internal/cli"
) )
// version is the build version. The Makefile and Dockerfile set it at link // Version is the build version. The Makefile and Dockerfile set it at link
// time with -ldflags "-X main.version=...". // time with -ldflags "-X main.Version=...".
var version = "dev" //
//nolint:gochecknoglobals // set by the linker; there is no other way
var Version = "dev"
func main() { func main() {
err := cli.New(cli.Params{Version: version}).Execute() err := cli.New(cli.Params{Version: Version, ConfigDir: ""}).Execute()
if err != nil { if err != nil {
os.Exit(1) os.Exit(1)
} }
+17 -1
View File
@@ -3,16 +3,32 @@ module sneak.berlin/go/x
go 1.26.0 go 1.26.0
require ( require (
github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcutil v1.1.6
github.com/kavfixnel/words v1.0.0 github.com/kavfixnel/words v1.0.0
github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1 github.com/stretchr/testify v1.12.1
github.com/tyler-smith/go-bip39 v1.1.0 github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
sneak.berlin/go/secret v1.0.0-rc4
) )
require ( require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.49.0 // indirect golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect golang.org/x/text v0.43.0 // indirect
+137 -1
View File
@@ -1,30 +1,166 @@
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/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/kavfixnel/words v1.0.0 h1:k7/KXUnLIVNCAlBLXhdq1GunnxXmQqglZooHA+NRzuQ= github.com/kavfixnel/words v1.0.0 h1:k7/KXUnLIVNCAlBLXhdq1GunnxXmQqglZooHA+NRzuQ=
github.com/kavfixnel/words v1.0.0/go.mod h1:i2LITxcnsJnKfT+ruhPGVwzdcRDnsgQTL9TIooX9XAU= github.com/kavfixnel/words v1.0.0/go.mod h1:i2LITxcnsJnKfT+ruhPGVwzdcRDnsgQTL9TIooX9XAU=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
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/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
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 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
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-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.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
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-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-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-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.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sneak.berlin/go/secret v1.0.0-rc4 h1:BFpPs34qhcYS2ZtK+nvWmBT7CGFgjYWY9KA9Amske3w=
sneak.berlin/go/secret v1.0.0-rc4/go.mod h1:pvhNbatHqGCO9l7hXYjlKEHkpGh7u8q/uE7mzP9EZXo=
-82
View File
@@ -1,82 +0,0 @@
package cli
import (
"fmt"
"io"
"github.com/spf13/cobra"
"sneak.berlin/go/x/pkg/cbk"
)
// maxMnemonicInputBytes bounds how much of stdin `cbk derive` will read; a
// 24-word mnemonic is well under 300 bytes.
const maxMnemonicInputBytes = 64 << 10
// newCBKCommand returns `sutil cbk`, the parent of the crypto_box
// keypair commands.
func newCBKCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cbk",
Short: "crypto_box keypairs derived from BIP39 mnemonics",
}
cmd.AddCommand(newCBKDeriveCommand())
return cmd
}
// newCBKDeriveCommand returns `sutil cbk derive`.
func newCBKDeriveCommand() *cobra.Command {
var publicOnly bool
cmd := &cobra.Command{
Use: "derive",
Short: "Derive a crypto_box keypair from a mnemonic read on stdin",
Long: `Reads a BIP39 mnemonic from stdin, in any letter case and with any
whitespace between words, and prints the crypto_box keypair derived from it
as hex. The same mnemonic always yields the same keypair.
The mnemonic is read from stdin rather than taken as an argument so that it
does not end up in shell history or process listings:
sutil generate mnemonic > mnemonic.txt
sutil cbk derive < mnemonic.txt`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
input := io.LimitReader(cmd.InOrStdin(), maxMnemonicInputBytes)
mnemonic, err := io.ReadAll(input)
if err != nil {
return fmt.Errorf("reading mnemonic from stdin: %w", err)
}
keypair, err := cbk.New(cbk.Params{Mnemonic: string(mnemonic)})
if err != nil {
return err
}
return writeKeypair(cmd.OutOrStdout(), keypair, publicOnly)
},
}
cmd.Flags().BoolVar(&publicOnly, "public", false, "print only the public key")
return cmd
}
// writeKeypair prints the keypair as labeled hex lines, or just the bare
// public key when publicOnly is set.
func writeKeypair(w io.Writer, keypair cbk.CryptoBoxKeypair, publicOnly bool) error {
if publicOnly {
_, err := fmt.Fprintln(w, keypair.PublicKeyHex())
return err
}
_, err := fmt.Fprintf(
w, "public: %s\nprivate: %s\n", keypair.PublicKeyHex(), keypair.PrivateKeyHex(),
)
return err
}
+31 -7
View File
@@ -7,6 +7,7 @@ package cli
import ( import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
) )
// Params configures New. // Params configures New.
@@ -14,25 +15,48 @@ type Params struct {
// Version is the version string embedded at build time. It is shown // Version is the version string embedded at build time. It is shown
// by `sutil version`, `sutil --version`, and `sutil info`. // by `sutil version`, `sutil --version`, and `sutil info`.
Version string Version string
// ConfigDir is the directory searched for the optional .sneak
// configuration file. Empty means the user's home directory; tests set
// it to a temporary directory so the real home is never consulted.
ConfigDir string
}
// app carries the state shared by every command: the build parameters,
// the --config flag, and the configuration loaded before any command runs.
type app struct {
params Params
configFile string
config *viper.Viper
} }
// New builds the root sutil command with every subcommand attached. // New builds the root sutil command with every subcommand attached.
func New(p Params) *cobra.Command { func New(p Params) *cobra.Command {
a := &app{params: p, configFile: "", config: nil}
root := &cobra.Command{ root := &cobra.Command{
Use: "sutil", Use: "sutil",
Short: "sneak's shell utility", Short: "sneak's shell utility",
Long: `sutil is a small collection of shell utilities built on the packages in Long: `sutil is a small collection of shell utilities worth having on every
sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords, machine: a fast pseudorandom byte stream and a memorable password generator.
BIP39 mnemonics, and crypto_box keypairs derived from them.`,
Version: p.Version, Before any command runs, sutil loads the optional configuration file named
SilenceUsage: true, by --config, or else ~/.sneak.yaml (or .json, .toml, ...) if it exists, and
makes environment variables available alongside it.`,
Version: p.Version,
SilenceUsage: true,
PersistentPreRunE: a.loadConfig,
} }
root.PersistentFlags().StringVar(
&a.configFile, "config", "",
"config file (default is "+configName+".* in the home directory)",
)
root.AddCommand( root.AddCommand(
newVersionCommand(p), newVersionCommand(p),
newInfoCommand(p), newInfoCommand(a),
newGenerateCommand(), newGenerateCommand(),
newCBKCommand(),
) )
return root return root
+82 -73
View File
@@ -3,6 +3,8 @@ package cli_test
import ( import (
"bytes" "bytes"
"errors" "errors"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -11,29 +13,14 @@ import (
"sneak.berlin/go/x/internal/cli" "sneak.berlin/go/x/internal/cli"
"sneak.berlin/go/x/internal/pwgen" "sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk"
) )
const ( const testVersion = "v1.2.3-test"
testVersion = "v1.2.3-test"
// vectorRepeats is how many times "abandon" appears in the standard
// BIP39 test vector for all-zero 128-bit entropy, which ends with
// "about".
vectorRepeats = bip39wordlist.MinWordCount - 1
)
// vectorMnemonic returns the standard BIP39 test vector for all-zero
// 128-bit entropy.
func vectorMnemonic() string {
return strings.Repeat("abandon ", vectorRepeats) + "about"
}
func TestVersionCommand(t *testing.T) { func TestVersionCommand(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "version") output, err := execute(t, "version")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, testVersion+"\n", output) assert.Equal(t, testVersion+"\n", output)
} }
@@ -41,7 +28,7 @@ func TestVersionCommand(t *testing.T) {
func TestVersionFlag(t *testing.T) { func TestVersionFlag(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "--version") output, err := execute(t, "--version")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, output, testVersion) assert.Contains(t, output, testVersion)
} }
@@ -49,25 +36,75 @@ func TestVersionFlag(t *testing.T) {
func TestInfoCommand(t *testing.T) { func TestInfoCommand(t *testing.T) {
t.Parallel() t.Parallel()
output, err := execute(t, "", "info") output, err := execute(t, "info")
require.NoError(t, err) require.NoError(t, err)
assert.Contains(t, output, "version: "+testVersion+"\n") assert.Contains(t, output, "version: "+testVersion+"\n")
assert.Contains(t, output, "go: go") assert.Contains(t, output, "go: go")
assert.Contains(t, output, "module: sneak.berlin/go/x\n") assert.Contains(t, output, "module: sneak.berlin/go/x\n")
assert.Contains(t, output, "config: (none)\n")
}
func TestConfigFileDiscoveredInConfigDir(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
path := filepath.Join(configDir, ".sneak.yaml")
require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600))
params := cli.Params{Version: testVersion, ConfigDir: configDir}
output, err := executeWithParams(t, params, "info")
require.NoError(t, err)
assert.Contains(t, output, "Using config file: "+path+"\n")
assert.Contains(t, output, "config: "+path+"\n")
}
func TestConfigFlagLoadsNamedFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "elsewhere.yaml")
require.NoError(t, os.WriteFile(path, []byte("example: 1\n"), 0o600))
output, err := execute(t, "--config", path, "info")
require.NoError(t, err)
assert.Contains(t, output, "Using config file: "+path+"\n")
assert.Contains(t, output, "config: "+path+"\n")
}
func TestConfigFlagRejectsMissingFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "missing.yaml")
_, err := execute(t, "--config", path, "version")
require.Error(t, err)
}
func TestConfigRejectsMalformedFile(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
path := filepath.Join(configDir, ".sneak.yaml")
require.NoError(t, os.WriteFile(path, []byte("not: [valid\n"), 0o600))
params := cli.Params{Version: testVersion, ConfigDir: configDir}
_, err := executeWithParams(t, params, "version")
require.Error(t, err)
} }
func TestGenerateRandomSeededIsDeterministic(t *testing.T) { func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
t.Parallel() t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "4096", "seed") first, err := execute(t, "generate", "random", "--count", "4096", "seed")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, first, 4096) assert.Len(t, first, 4096)
second, err := execute(t, "", "generate", "random", "--count", "4096", "seed") second, err := execute(t, "generate", "random", "--count", "4096", "seed")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, first, second) assert.Equal(t, first, second)
other, err := execute(t, "", "gen", "random", "-c", "4096", "other seed") other, err := execute(t, "gen", "random", "-c", "4096", "other seed")
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, first, other) assert.NotEqual(t, first, other)
} }
@@ -75,11 +112,11 @@ func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
func TestGenerateRandomUnseeded(t *testing.T) { func TestGenerateRandomUnseeded(t *testing.T) {
t.Parallel() t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "1024") first, err := execute(t, "generate", "random", "--count", "1024")
require.NoError(t, err) require.NoError(t, err)
assert.Len(t, first, 1024) assert.Len(t, first, 1024)
second, err := execute(t, "", "generate", "random", "--count", "1024") second, err := execute(t, "generate", "random", "--count", "1024")
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, first, second) assert.NotEqual(t, first, second)
} }
@@ -87,28 +124,10 @@ func TestGenerateRandomUnseeded(t *testing.T) {
func TestGenerateRandomRejectsExtraArguments(t *testing.T) { func TestGenerateRandomRejectsExtraArguments(t *testing.T) {
t.Parallel() t.Parallel()
_, err := execute(t, "", "generate", "random", "one", "two") _, err := execute(t, "generate", "random", "one", "two")
require.Error(t, err) require.Error(t, err)
} }
func TestGenerateMnemonic(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "generate", "mnemonic")
require.NoError(t, err)
mnemonic := strings.TrimSuffix(output, "\n")
require.NoError(t, bip39wordlist.ValidateWordlist(mnemonic))
assert.Len(t, strings.Fields(mnemonic), bip39wordlist.MaxWordCount)
output, err = execute(t, "", "generate", "mnemonic", "--words", "12")
require.NoError(t, err)
assert.Len(t, strings.Fields(output), bip39wordlist.MinWordCount)
_, err = execute(t, "", "generate", "mnemonic", "--words", "13")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
}
func TestGeneratePassword(t *testing.T) { func TestGeneratePassword(t *testing.T) {
t.Parallel() t.Parallel()
@@ -119,7 +138,7 @@ func TestGeneratePassword(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
output, err := execute(t, "", "generate", "password") output, err := execute(t, "generate", "password")
require.NoError(t, err) require.NoError(t, err)
password := strings.TrimSuffix(output, "\n") password := strings.TrimSuffix(output, "\n")
@@ -129,46 +148,36 @@ func TestGeneratePassword(t *testing.T) {
assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password) assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password)
} }
func TestCBKDerive(t *testing.T) { func TestUnknownCommandFails(t *testing.T) {
t.Parallel() t.Parallel()
expected, err := cbk.FromMnemonic(vectorMnemonic()) _, err := execute(t, "cbk")
require.NoError(t, err) require.Error(t, err)
stdin := " " + strings.ToUpper(vectorMnemonic()) + "\n"
output, err := execute(t, stdin, "cbk", "derive")
require.NoError(t, err)
assert.Equal(t,
"public: "+expected.PublicKeyHex()+"\n"+
"private: "+expected.PrivateKeyHex()+"\n",
output,
)
output, err = execute(t, vectorMnemonic(), "cbk", "derive", "--public")
require.NoError(t, err)
assert.Equal(t, expected.PublicKeyHex()+"\n", output)
} }
func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) { // execute runs sutil with the given arguments, looking for config in an
t.Parallel() // empty temporary directory, and returns everything it wrote to stdout and
// stderr.
_, err := execute(t, "not a mnemonic\n", "cbk", "derive") func execute(t *testing.T, args ...string) (string, error) {
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
}
// execute runs sutil with the given stdin and arguments and returns
// everything it wrote to stdout and stderr.
func execute(t *testing.T, stdin string, args ...string) (string, error) {
t.Helper() t.Helper()
root := cli.New(cli.Params{Version: testVersion}) params := cli.Params{Version: testVersion, ConfigDir: t.TempDir()}
return executeWithParams(t, params, args...)
}
// executeWithParams is execute with explicit Params.
func executeWithParams(
t *testing.T, params cli.Params, args ...string,
) (string, error) {
t.Helper()
root := cli.New(params)
var output bytes.Buffer var output bytes.Buffer
root.SetOut(&output) root.SetOut(&output)
root.SetErr(&output) root.SetErr(&output)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args) root.SetArgs(args)
err := root.Execute() err := root.Execute()
+73
View File
@@ -0,0 +1,73 @@
package cli
import (
"errors"
"fmt"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// configName is the base name of the optional configuration file looked
// up in the config directory; viper tries every extension it supports, so
// ~/.sneak.yaml, ~/.sneak.json, ~/.sneak.toml, and so on all work.
const configName = ".sneak"
// loadConfig runs before every command. It reads the optional
// configuration file into a.config and makes environment variables
// available through it. The file is the one named by --config, or else
// configName in Params.ConfigDir, which defaults to the user's home
// directory. A missing default file is not an error; a file that is named
// explicitly but missing, or one that cannot be parsed, is.
func (a *app) loadConfig(cmd *cobra.Command, _ []string) error {
configDir := a.params.ConfigDir
if configDir == "" {
home, err := homedir.Dir()
if err != nil {
return fmt.Errorf("finding home directory: %w", err)
}
configDir = home
}
config := viper.New()
config.AddConfigPath(configDir)
config.SetConfigName(configName)
config.AutomaticEnv()
if a.configFile != "" {
config.SetConfigFile(a.configFile)
}
err := config.ReadInConfig()
_, isNotFound := errors.AsType[viper.ConfigFileNotFoundError](err)
if isNotFound {
a.config = config
return nil
}
if err != nil {
return fmt.Errorf("reading config file: %w", err)
}
a.config = config
_, err = fmt.Fprintln(
cmd.ErrOrStderr(), "Using config file:", config.ConfigFileUsed(),
)
return err
}
// configFileUsed returns the path of the configuration file that
// loadConfig read, or an empty string if there was none.
func (a *app) configFileUsed() string {
if a.config == nil {
return ""
}
return a.config.ConfigFileUsed()
}
+1 -33
View File
@@ -8,7 +8,6 @@ import (
"sneak.berlin/go/x/internal/pwgen" "sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/internal/randstream" "sneak.berlin/go/x/internal/randstream"
"sneak.berlin/go/x/pkg/bip39wordlist"
) )
// streamChunkSize is the write size used by `generate random`. Large // streamChunkSize is the write size used by `generate random`. Large
@@ -21,13 +20,12 @@ func newGenerateCommand() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "generate", Use: "generate",
Aliases: []string{"gen"}, Aliases: []string{"gen"},
Short: "Generate random bytes, passwords, and mnemonics", Short: "Generate random bytes and passwords",
} }
cmd.AddCommand( cmd.AddCommand(
newGenerateRandomCommand(), newGenerateRandomCommand(),
newGeneratePasswordCommand(), newGeneratePasswordCommand(),
newGenerateMnemonicCommand(),
) )
return cmd return cmd
@@ -107,36 +105,6 @@ no usable word list.`,
} }
} }
// newGenerateMnemonicCommand returns `sutil generate mnemonic`.
func newGenerateMnemonicCommand() *cobra.Command {
var wordCount int
cmd := &cobra.Command{
Use: "mnemonic",
Short: "Print a new random BIP39 mnemonic phrase",
Long: `Prints a new BIP39 mnemonic phrase generated from crypto/rand. The
default of 24 words encodes 256 bits of entropy; 12 words encodes 128.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
mnemonic, err := bip39wordlist.GenerateMnemonic(wordCount)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
return err
},
}
cmd.Flags().IntVarP(
&wordCount, "words", "w", bip39wordlist.MaxWordCount,
"number of words: 12, 15, 18, 21, or 24",
)
return cmd
}
// streamBytes copies limit bytes (or everything, when limit is zero) from // streamBytes copies limit bytes (or everything, when limit is zero) from
// src to dst in streamChunkSize chunks. // src to dst in streamChunkSize chunks.
func streamBytes(dst io.Writer, src io.Reader, limit int64) error { func streamBytes(dst io.Writer, src io.Reader, limit int64) error {
+15 -5
View File
@@ -15,31 +15,41 @@ import (
// module support. // module support.
var ErrNoBuildInfo = errors.New("sutil: no build information in this binary") var ErrNoBuildInfo = errors.New("sutil: no build information in this binary")
// noConfigFile is what `sutil info` prints when no config file was loaded.
const noConfigFile = "(none)"
// newInfoCommand returns `sutil info`, which prints the build version, the // newInfoCommand returns `sutil info`, which prints the build version, the
// Go toolchain version, the module path, and the VCS metadata recorded by // Go toolchain version, the module path, the configuration file in use,
// the Go toolchain at build time. // and the VCS metadata recorded by the Go toolchain at build time.
func newInfoCommand(p Params) *cobra.Command { func newInfoCommand(a *app) *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "info", Use: "info",
Short: "Print build information", Short: "Print build information",
Args: cobra.NoArgs, Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
return writeBuildInfo(cmd.OutOrStdout(), p.Version) return writeBuildInfo(
cmd.OutOrStdout(), a.params.Version, a.configFileUsed(),
)
}, },
} }
} }
// writeBuildInfo writes one "key: value" line per fact to w. // writeBuildInfo writes one "key: value" line per fact to w.
func writeBuildInfo(w io.Writer, version string) error { func writeBuildInfo(w io.Writer, version, configFile string) error {
info, ok := debug.ReadBuildInfo() info, ok := debug.ReadBuildInfo()
if !ok { if !ok {
return ErrNoBuildInfo return ErrNoBuildInfo
} }
if configFile == "" {
configFile = noConfigFile
}
lines := []string{ lines := []string{
"version: " + version, "version: " + version,
"go: " + info.GoVersion, "go: " + info.GoVersion,
"module: " + info.Main.Path, "module: " + info.Main.Path,
"config: " + configFile,
} }
for _, setting := range info.Settings { for _, setting := range info.Settings {
+57 -124
View File
@@ -1,98 +1,60 @@
// Package cbk derives NaCl crypto_box keypairs (Curve25519, XSalsa20, // Package cbk derives NaCl crypto_box keypairs (Curve25519) deterministically
// Poly1305) deterministically from BIP39 mnemonic phrases, and seals and // from BIP39 mnemonic phrases using BIP85. That is all it does; the
// opens messages with them. // resulting keys are plain 32-byte crypto_box keys for use with
// golang.org/x/crypto/nacl/box.
// //
// # Key derivation // The same mnemonic and index always produce the same keypair:
//
// The same mnemonic and passphrase always produce the same keypair:
// //
// 1. The mnemonic is validated and canonicalized with // 1. The mnemonic is validated and canonicalized with
// bip39wordlist.NormalizeWordlist (lowercase, single spaces). // bip39wordlist.NormalizeWordlist (lowercase, single spaces).
// 2. The standard 64-byte BIP39 seed is computed from the canonical // 2. The standard 64-byte BIP39 seed is computed from the canonical
// mnemonic and the optional passphrase with PBKDF2-HMAC-SHA512 (2048 // mnemonic with PBKDF2-HMAC-SHA512 (2048 rounds, salt "mnemonic"),
// rounds, salt "mnemonic" + passphrase), exactly as a BIP39 wallet // exactly as a wallet would with an empty passphrase. BIP39
// would. // passphrases are deliberately not supported.
// 3. The 32-byte crypto_box private key is derived from that seed with // 3. The seed becomes a BIP32 master key.
// HKDF-SHA256, using no salt and DerivationInfo as the info string. // 4. BIP85 (sneak.berlin/go/secret/pkg/bip85) derives 32 bytes of
// 4. The public key is the Curve25519 scalar multiplication of the // application entropy from the master key with the HEX application,
// at path m/83696968'/128169'/32'/{index}'. Those bytes are the
// crypto_box private key.
// 5. The public key is the Curve25519 scalar multiplication of the
// private key with the base point, via golang.org/x/crypto/nacl/box. // private key with the base point, via golang.org/x/crypto/nacl/box.
// //
// # Sealing // Because the entropy comes from BIP85, any BIP85-capable tool given the
// // same mnemonic reproduces the private key, and the index lets one
// Seal and Open wrap box.Seal and box.Open. A sealed message is the // mnemonic back any number of independent keypairs.
// 24-byte random nonce followed by the box ciphertext, so it can be stored
// or transmitted as a single opaque byte string.
package cbk package cbk
import ( import (
"bytes" "bytes"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
"sneak.berlin/go/secret/pkg/bip85"
"sneak.berlin/go/x/pkg/bip39wordlist" "sneak.berlin/go/x/pkg/bip39wordlist"
) )
const ( const (
// KeySize is the length in bytes of a crypto_box public or private key. // KeySize is the length in bytes of a crypto_box public or private key.
// It is also the byte count requested from the BIP85 HEX application.
KeySize = 32 KeySize = 32
// NonceSize is the length in bytes of the nonce that prefixes every // MaxIndex is the largest valid Params.Index. BIP85 path components
// sealed message. // are hardened BIP32 child indexes, which leaves 31 bits.
NonceSize = 24 MaxIndex = hdkeychain.HardenedKeyStart - 1
// DerivationInfo is the HKDF info string that binds derived keys to
// this package. Changing it changes every derived key.
DerivationInfo = "sneak.berlin/go/x/pkg/cbk crypto_box v1"
hexKeyLength = KeySize * 2
) )
var ( // ErrIndexTooLarge is returned by New when Params.Index exceeds MaxIndex.
// ErrInvalidKeyHex is returned by KeyFromHex for input that is not var ErrIndexTooLarge = errors.New("cbk: index exceeds the BIP32 hardened range")
// exactly 64 hexadecimal characters.
ErrInvalidKeyHex = errors.New("cbk: key must be 64 hexadecimal characters")
// ErrSealedTooShort is returned by Open when the input is shorter than
// the nonce that must prefix it.
ErrSealedTooShort = errors.New("cbk: sealed message is shorter than a nonce")
// ErrOpenFailed is returned by Open when authentication fails: the
// message was sealed for a different keypair, by a different sender,
// or has been modified.
ErrOpenFailed = errors.New("cbk: open failed: wrong keys or modified message")
)
// CryptoBoxKey is a 32-byte crypto_box key, either public or private. // CryptoBoxKey is a 32-byte crypto_box key, either public or private.
type CryptoBoxKey [KeySize]byte type CryptoBoxKey [KeySize]byte
// KeyFromHex parses a key from its 64-character hexadecimal encoding, as
// produced by Hex.
func KeyFromHex(encoded string) (CryptoBoxKey, error) {
var key CryptoBoxKey
if len(encoded) != hexKeyLength {
return key, fmt.Errorf(
"%w: got %d characters", ErrInvalidKeyHex, len(encoded),
)
}
decoded, err := hex.DecodeString(encoded)
if err != nil {
return key, fmt.Errorf("%w: %w", ErrInvalidKeyHex, err)
}
copy(key[:], decoded)
return key, nil
}
// Bytes returns a copy of the key as a byte slice. // Bytes returns a copy of the key as a byte slice.
func (k CryptoBoxKey) Bytes() []byte { func (k CryptoBoxKey) Bytes() []byte {
return k[:] return k[:]
@@ -109,10 +71,10 @@ type Params struct {
// whitespace between words is accepted; it is normalized before use. // whitespace between words is accepted; it is normalized before use.
Mnemonic string Mnemonic string
// Passphrase is the optional BIP39 passphrase (sometimes called the // Index selects which of the keypairs derivable from the mnemonic to
// "25th word"). It is used verbatim: it is case sensitive and is not // return. It is the final component of the BIP85 derivation path, so
// trimmed. An empty passphrase is the common case. // each index yields an independent keypair. Zero is the first.
Passphrase string Index uint32
} }
// CryptoBoxKeypair is a crypto_box public and private key pair. // CryptoBoxKeypair is a crypto_box public and private key pair.
@@ -121,20 +83,37 @@ type CryptoBoxKeypair struct {
PrivateKey CryptoBoxKey PrivateKey CryptoBoxKey
} }
// New derives the keypair for the mnemonic and passphrase in p. If the // New derives the keypair for the mnemonic and index in p. If the mnemonic
// mnemonic is not valid it returns an error wrapping one of the // is not valid it returns an error wrapping one of the bip39wordlist
// bip39wordlist sentinel errors. // sentinel errors.
func New(p Params) (CryptoBoxKeypair, error) { func New(p Params) (CryptoBoxKeypair, error) {
if p.Index > MaxIndex {
return CryptoBoxKeypair{}, fmt.Errorf("%w: %d", ErrIndexTooLarge, p.Index)
}
mnemonic, err := bip39wordlist.NormalizeWordlist(p.Mnemonic) mnemonic, err := bip39wordlist.NormalizeWordlist(p.Mnemonic)
if err != nil { if err != nil {
return CryptoBoxKeypair{}, err return CryptoBoxKeypair{}, err
} }
seed := bip39.NewSeed(mnemonic, p.Passphrase) // The seed is always computed with an empty BIP39 passphrase. The
// network parameters only choose the version bytes used when a key is
privateKeyBytes, err := hkdf.Key(sha256.New, seed, nil, DerivationInfo, KeySize) // serialized; they do not affect derivation.
masterKey, err := hdkeychain.NewMaster(
bip39.NewSeed(mnemonic, ""), &chaincfg.MainNetParams,
)
if err != nil { if err != nil {
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving private key: %w", err) return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP32 master key: %w", err)
}
privateKeyHex, err := bip85.DeriveHex(masterKey, KeySize, p.Index)
if err != nil {
return CryptoBoxKeypair{}, fmt.Errorf("cbk: deriving BIP85 entropy: %w", err)
}
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return CryptoBoxKeypair{}, fmt.Errorf("cbk: decoding BIP85 entropy: %w", err)
} }
publicKey, privateKey, err := box.GenerateKey(bytes.NewReader(privateKeyBytes)) publicKey, privateKey, err := box.GenerateKey(bytes.NewReader(privateKeyBytes))
@@ -148,10 +127,10 @@ func New(p Params) (CryptoBoxKeypair, error) {
}, nil }, nil
} }
// FromMnemonic derives the keypair for mnemonic with an empty passphrase. // FromMnemonic derives the keypair for mnemonic at index zero. It is
// It is shorthand for New(Params{Mnemonic: mnemonic}). // shorthand for New(Params{Mnemonic: mnemonic}).
func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) { func FromMnemonic(mnemonic string) (CryptoBoxKeypair, error) {
return New(Params{Mnemonic: mnemonic}) return New(Params{Mnemonic: mnemonic, Index: 0})
} }
// PublicKeyBytes returns a copy of the public key as a byte slice. // PublicKeyBytes returns a copy of the public key as a byte slice.
@@ -175,49 +154,3 @@ func (kp CryptoBoxKeypair) PublicKeyHex() string {
func (kp CryptoBoxKeypair) PrivateKeyHex() string { func (kp CryptoBoxKeypair) PrivateKeyHex() string {
return kp.PrivateKey.Hex() return kp.PrivateKey.Hex()
} }
// Seal encrypts and authenticates message from this keypair to recipient.
// The result is a fresh random nonce followed by the ciphertext, so it is
// NonceSize + len(message) + box.Overhead bytes long. Only the holder of
// recipient's private key, knowing this keypair's public key, can Open it.
func (kp CryptoBoxKeypair) Seal(
message []byte, recipient CryptoBoxKey,
) ([]byte, error) {
var nonce [NonceSize]byte
_, err := rand.Read(nonce[:])
if err != nil {
return nil, fmt.Errorf("cbk: generating nonce: %w", err)
}
peersPublicKey := [KeySize]byte(recipient)
privateKey := [KeySize]byte(kp.PrivateKey)
return box.Seal(nonce[:], message, &nonce, &peersPublicKey, &privateKey), nil
}
// Open authenticates and decrypts a message that sender produced with Seal
// for this keypair. It returns ErrSealedTooShort if sealed cannot contain a
// nonce and ErrOpenFailed if authentication fails.
func (kp CryptoBoxKeypair) Open(
sealed []byte, sender CryptoBoxKey,
) ([]byte, error) {
if len(sealed) < NonceSize {
return nil, ErrSealedTooShort
}
var nonce [NonceSize]byte
copy(nonce[:], sealed[:NonceSize])
peersPublicKey := [KeySize]byte(sender)
privateKey := [KeySize]byte(kp.PrivateKey)
ciphertext := sealed[NonceSize:]
message, ok := box.Open(nil, ciphertext, &nonce, &peersPublicKey, &privateKey)
if !ok {
return nil, ErrOpenFailed
}
return message, nil
}
+108 -129
View File
@@ -4,83 +4,140 @@ import (
"strings" "strings"
"testing" "testing"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/nacl/box" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
"sneak.berlin/go/x/pkg/bip39wordlist" "sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk" "sneak.berlin/go/x/pkg/cbk"
) )
// vectorRepeats is how many times "abandon" appears in the standard BIP39 // The BIP85 specification publishes its test vectors against one master
// test vector for all-zero 128-bit entropy, which ends with "about". // key. specMnemonic is the BIP39 phrase that produces that master key, so
const vectorRepeats = bip39wordlist.MinWordCount - 1 // the whole mnemonic-to-entropy chain this package relies on can be
// checked against the specification.
// The keys below were pinned when the derivation was first implemented.
// If this test starts failing, the derivation has changed and every key
// anyone has derived with this package is no longer reproducible.
const ( const (
vectorPublicKeyHex = "db9c1ec248805579d0b0fe2d5a4814f96009f66a11c91260487b79167f881b20" specMnemonic = "install scatter logic circle pencil average fall shoe " +
"quantum disease suspect usage"
vectorPrivateKeyHex = "eec3d5da4e348bd16df93b61c595bb5f75645f9aa468e72513be033a72b3d053" specMasterKey = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8" +
"kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// specHex64 is the specification's HEX application vector for
// m/83696968'/128169'/64'/0'.
specHex64 = "492db4698cf3b73a5a24998aa3e9d7fa96275d85724a91e7" +
"1aa2d645442f878555d078fd1f1f67e368976f04137b1f7a" +
"0d19232136ca50c44614af72b5582a5c"
) )
// vectorMnemonic returns the standard BIP39 test vector for all-zero // The keys below are the keypair for specMnemonic at index 0, pinned when
// 128-bit entropy. // the BIP85 derivation was implemented. If this test starts failing, the
func vectorMnemonic() string { // derivation has changed and existing keys are no longer reproducible.
return strings.Repeat("abandon ", vectorRepeats) + "about" const (
vectorPublicKeyHex = "dbef711b3f340d336eb99cd8d0dbd491" +
"e1edce8eaad71cd66be228032a9a743e"
vectorPrivateKeyHex = "ea3ceb0b02ee8e587779c63f4b7b3a21" +
"e950a213f1ec53cab608d13e8796e6dc"
)
func TestSpecMnemonicProducesSpecMasterKey(t *testing.T) {
t.Parallel()
masterKey := specMasterKeyFromMnemonic(t)
assert.Equal(t, specMasterKey, masterKey.String())
hex64, err := bip85.DeriveHex(masterKey, 64, 0)
require.NoError(t, err)
assert.Equal(t, specHex64, hex64)
} }
func TestNewMatchesPinnedVector(t *testing.T) { func TestNewMatchesPinnedVector(t *testing.T) {
t.Parallel() t.Parallel()
keypair, err := cbk.FromMnemonic(vectorMnemonic()) keypair, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex()) assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex())
assert.Equal(t, vectorPrivateKeyHex, keypair.PrivateKeyHex()) assert.Equal(t, vectorPrivateKeyHex, keypair.PrivateKeyHex())
} }
func TestPrivateKeyIsBIP85HexApplicationOutput(t *testing.T) {
t.Parallel()
masterKey := specMasterKeyFromMnemonic(t)
for _, index := range []uint32{0, 1, 7} {
expected, err := bip85.DeriveHex(masterKey, cbk.KeySize, index)
require.NoError(t, err)
keypair, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Index: index})
require.NoError(t, err)
assert.Equal(t, expected, keypair.PrivateKeyHex(), "index %d", index)
}
}
func TestNewIndexSelectsIndependentKeypairs(t *testing.T) {
t.Parallel()
first, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Index: 0})
require.NoError(t, err)
second, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Index: 1})
require.NoError(t, err)
assert.NotEqual(t, first.PublicKey, second.PublicKey)
assert.NotEqual(t, first.PrivateKey, second.PrivateKey)
fromMnemonic, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err)
assert.Equal(t, first, fromMnemonic, "FromMnemonic uses index 0")
}
func TestNewRejectsIndexBeyondHardenedRange(t *testing.T) {
t.Parallel()
_, err := cbk.New(cbk.Params{Mnemonic: specMnemonic, Index: cbk.MaxIndex + 1})
require.ErrorIs(t, err, cbk.ErrIndexTooLarge)
_, err = cbk.New(cbk.Params{Mnemonic: specMnemonic, Index: cbk.MaxIndex})
require.NoError(t, err)
}
func TestNewNormalizesMnemonic(t *testing.T) { func TestNewNormalizesMnemonic(t *testing.T) {
t.Parallel() t.Parallel()
canonical, err := cbk.FromMnemonic(vectorMnemonic()) canonical, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err) require.NoError(t, err)
messy, err := cbk.New(cbk.Params{ words := strings.Fields(specMnemonic)
Mnemonic: " ABANDON\tAbandon " + strings.Repeat("abandon ", 9) + messy := " " + strings.ToUpper(words[0]) + "\t" +
"\nabout\n", strings.Join(words[1:], " ") + "\n"
Passphrase: "",
}) normalized, err := cbk.New(cbk.Params{Mnemonic: messy, Index: 0})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, canonical, messy) assert.Equal(t, canonical, normalized)
} }
func TestNewPassphraseChangesKeys(t *testing.T) { func TestNewDifferentMnemonicsGiveDifferentKeys(t *testing.T) {
t.Parallel() t.Parallel()
plain, err := cbk.FromMnemonic(vectorMnemonic()) other, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
require.NoError(t, err) require.NoError(t, err)
extraWord := "TREZOR" specKeypair, err := cbk.FromMnemonic(specMnemonic)
withExtraWord, err := cbk.New(cbk.Params{
Mnemonic: vectorMnemonic(),
Passphrase: extraWord,
})
require.NoError(t, err) require.NoError(t, err)
assert.NotEqual(t, plain.PublicKey, withExtraWord.PublicKey) otherKeypair, err := cbk.FromMnemonic(other)
assert.NotEqual(t, plain.PrivateKey, withExtraWord.PrivateKey)
again, err := cbk.New(cbk.Params{
Mnemonic: vectorMnemonic(),
Passphrase: extraWord,
})
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, withExtraWord, again, "derivation is deterministic") assert.NotEqual(t, specKeypair.PublicKey, otherKeypair.PublicKey)
assert.NotEqual(t, specKeypair.PrivateKey, otherKeypair.PrivateKey)
} }
func TestNewRejectsInvalidMnemonic(t *testing.T) { func TestNewRejectsInvalidMnemonic(t *testing.T) {
@@ -89,14 +146,17 @@ func TestNewRejectsInvalidMnemonic(t *testing.T) {
_, err := cbk.FromMnemonic("abandon bogus about") _, err := cbk.FromMnemonic("abandon bogus about")
require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount) require.ErrorIs(t, err, bip39wordlist.ErrInvalidWordCount)
_, err = cbk.FromMnemonic(strings.Repeat("abandon ", vectorRepeats) + "bogus") words := strings.Fields(specMnemonic)
words[len(words)-1] = "bogus"
_, err = cbk.FromMnemonic(strings.Join(words, " "))
require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord) require.ErrorIs(t, err, bip39wordlist.ErrUnknownWord)
} }
func TestKeyAccessors(t *testing.T) { func TestKeyAccessors(t *testing.T) {
t.Parallel() t.Parallel()
keypair, err := cbk.FromMnemonic(vectorMnemonic()) keypair, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes()) assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes())
@@ -105,99 +165,18 @@ func TestKeyAccessors(t *testing.T) {
assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize) assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize)
assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex()) assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex())
assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex()) assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex())
assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize)
} }
func TestKeyFromHexRoundTrip(t *testing.T) { // specMasterKeyFromMnemonic builds the BIP32 master key from specMnemonic
t.Parallel() // the same way the package does, independently of it.
func specMasterKeyFromMnemonic(t *testing.T) *hdkeychain.ExtendedKey {
keypair, err := cbk.FromMnemonic(vectorMnemonic())
require.NoError(t, err)
parsed, err := cbk.KeyFromHex(keypair.PublicKeyHex())
require.NoError(t, err)
assert.Equal(t, keypair.PublicKey, parsed)
}
func TestKeyFromHexRejectsBadInput(t *testing.T) {
t.Parallel()
_, err := cbk.KeyFromHex("abcd")
require.ErrorIs(t, err, cbk.ErrInvalidKeyHex)
notHex := "zz" + keypairForTest(t).PublicKeyHex()[2:]
_, err = cbk.KeyFromHex(notHex)
require.ErrorIs(t, err, cbk.ErrInvalidKeyHex)
}
func TestSealOpenRoundTrip(t *testing.T) {
t.Parallel()
alice := keypairForTest(t)
bob := keypairForTest(t)
message := []byte("the quick brown fox jumps over the lazy dog")
sealed, err := alice.Seal(message, bob.PublicKey)
require.NoError(t, err)
assert.Len(t, sealed, cbk.NonceSize+len(message)+box.Overhead)
opened, err := bob.Open(sealed, alice.PublicKey)
require.NoError(t, err)
assert.Equal(t, message, opened)
}
func TestSealUsesFreshNonces(t *testing.T) {
t.Parallel()
alice := keypairForTest(t)
bob := keypairForTest(t)
message := []byte("same message twice")
first, err := alice.Seal(message, bob.PublicKey)
require.NoError(t, err)
second, err := alice.Seal(message, bob.PublicKey)
require.NoError(t, err)
assert.NotEqual(t, first, second)
}
func TestOpenRejectsTamperingAndWrongKeys(t *testing.T) {
t.Parallel()
alice := keypairForTest(t)
bob := keypairForTest(t)
eve := keypairForTest(t)
message := []byte("for bob only")
sealed, err := alice.Seal(message, bob.PublicKey)
require.NoError(t, err)
_, err = eve.Open(sealed, alice.PublicKey)
require.ErrorIs(t, err, cbk.ErrOpenFailed, "wrong recipient")
_, err = bob.Open(sealed, eve.PublicKey)
require.ErrorIs(t, err, cbk.ErrOpenFailed, "wrong sender")
tampered := append([]byte(nil), sealed...)
tampered[len(tampered)-1] ^= 0x01
_, err = bob.Open(tampered, alice.PublicKey)
require.ErrorIs(t, err, cbk.ErrOpenFailed, "modified ciphertext")
_, err = bob.Open(sealed[:cbk.NonceSize-1], alice.PublicKey)
require.ErrorIs(t, err, cbk.ErrSealedTooShort)
}
// keypairForTest derives a keypair from a freshly generated mnemonic.
func keypairForTest(t *testing.T) cbk.CryptoBoxKeypair {
t.Helper() t.Helper()
mnemonic, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount) seed := bip39.NewSeed(specMnemonic, "")
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
require.NoError(t, err) require.NoError(t, err)
keypair, err := cbk.FromMnemonic(mnemonic) return masterKey
require.NoError(t, err)
return keypair
} }
+104
View File
@@ -0,0 +1,104 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tools, go, node and yarn come from
# nix, apt, brew, or apk (detected in that order), with no version and
# no hash, because the distribution is trusted. It installs node, yarn
# and the node packages only when yarn.lock is in the tree. Assumes
# nothing is present.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
PKGMGR=""
SUDO=""
APT_UPDATED=""
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)
# Package lists may be empty (fresh images); refresh once per run.
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$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
}
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
# Go on the host is for script/fmt and script/fmt-check (gofmt) and
# for make build; the gates compile inside the Dockerfile's phases.
if missing go; then pkg_install go golang-go go go; fi
# A Dockerfile stage that needs no yarn copies no yarn.lock.
if [ -f yarn.lock ]; then
if missing node; then pkg_install nodejs nodejs node nodejs; fi
if missing yarn && missing yarnpkg; then
pkg_install yarn yarnpkg yarn yarn
fi
# Debian 13 and Ubuntu 26.04 package yarn 4, which refuses a yarn 1
# yarn.lock, or rewrites it in its own format.
yarn_version="$(run_yarn --version)"
if [ "${yarn_version%%.*}" != "1" ]; then
echo "bootstrap: yarn $yarn_version refuses this yarn 1 yarn.lock;" \
"use a distribution that packages yarn 1" >&2
exit 1
fi
run_yarn install --frozen-lockfile
fi
echo "bootstrap complete"
}
main "$@"
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. test and lint are Docker
# phases; fmt-check is native, because a formatter writes the working
# tree. 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 "$@"
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# line, since a cached gate phase is a check that did not run; unlike
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# The version and the tag each get their own line: a failing
# command substitution inside an argument does not trip `set -e`,
# so the inline form degrades silently to an empty constant. The
# VERSION build argument takes precedence over the version a build
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$tag" .
}
main "$@"
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# line, since a cached gate phase is a check that did not run; unlike
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# The version and the tag each get their own line: a failing
# command substitution inside an argument does not trip `set -e`,
# so the inline form degrades silently to an empty constant. The
# VERSION build argument takes precedence over the version a build
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$tag" .
}
main "$@"
Executable
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# script/fmt: format all files (writes): gofmt for Go, prettier for
# markdown.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
GO_DIRS="cmd internal pkg"
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
# shellcheck disable=SC2086 # GO_DIRS is a word list on purpose
gofmt -w $GO_DIRS
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only): gofmt for Go, prettier
# for markdown.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
GO_DIRS="cmd internal pkg"
# Debian and Ubuntu name the yarn command yarnpkg.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
yarn "$@"
elif command -v yarnpkg >/dev/null 2>&1; then
yarnpkg "$@"
else
echo "no yarn; run script/bootstrap first" >&2
exit 1
fi
}
main() {
cd "$ROOT"
# shellcheck disable=SC2086 # GO_DIRS is a word list on purpose
unformatted="$(gofmt -l $GO_DIRS)"
if [ -n "$unformatted" ]; then
echo "gofmt: the following files need formatting:" >&2
echo "$unformatted" >&2
exit 1
fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
}
main "$@"
+16
View 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' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
# script/lint: run the linter. Linting is a phase of the Dockerfile and
# this builds that phase alone; the linter is never installed or run on
# a developer host, where a shared result cache and a host-global lock
# make its answer untrustworthy.
#
# The phase is not the last stage in the file, so it is built only when
# --target names it. A new CACHEBUST value reruns every RUN step after
# the ARG CACHEBUST line, since a cached lint layer is a lint that did
# not run; unlike --no-cache, it keeps the layers above that line and
# the cache mounts. This relies on every stage of the Dockerfile
# declaring ARG CACHEBUST before every step that lints, tests or
# compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning.
# --output type=cacheonly writes no image, since nothing uses one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint \
--output type=cacheonly .
}
main "$@"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. The Go
# extra: go.mod and go.sum must already be tidy, so a commit never
# carries a module file that go mod tidy would rewrite.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
if ! git diff --quiet --exit-code -- go.mod go.sum; then
echo "precommit: go mod tidy changed go.mod or go.sum;" \
"stage them and retry" >&2
exit 1
fi
"$SCRIPT_DIR/check"
}
main "$@"
+12
View 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 "x"
}
main "$@"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"
Executable
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
# script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when
# named, and a new CACHEBUST value because a cached test layer is a test
# that did not run; unlike --no-cache, it keeps the layers above the ARG
# CACHEBUST line and the cache mounts. This relies on every stage of the
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
# or compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning. --output type=cacheonly writes no
# image, since nothing uses one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test \
--output type=cacheonly .
}
main "$@"