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
node_modules
.DS_Store
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own host-built artifacts, anchored.
/sutil
*.test
*.out
*.log
*.prof
coverage.*
**/*.test
**/*.out
**/*.log
**/*.prof
**/coverage.*
+6
View File
@@ -10,3 +10,9 @@ insert_final_newline = true
[Makefile]
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
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:
check:
runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps:
# actions/checkout v4.2.2, 2026-02-22
- 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/
*.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/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# 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
# Go test and profiling outputs.
*.test
*.out
*.log
+105 -15
View File
@@ -1,32 +1,122 @@
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:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- exhaustruct_v5 # Requires all struct fields
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- exhaustruct # Deprecated, replaced by exhaustruct_v5
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
funlen:
lines: 80
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:
exclude-use-default: false
max-issues-per-linter: 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
# formatting and lint failures surface in seconds, before any compilation:
# In every stage ARG CACHEBUST comes right after the steps that install
# 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)
# docs - prettier check of all markdown (make fmt-check-md)
# builder - tests (make test) and the release build
#
# The builder stage declares explicit dependencies on lint and docs so
# BuildKit cannot finish the build unless every check passed.
# This repository keeps cgo off, so its test phase departs from the
# canonical Go Dockerfile in REPO_POLICIES.md in one way: it runs on the
# alpine Go image without -race, which needs a C toolchain. Everything
# else follows the canonical file.
# Lint stage: fast feedback on Go formatting and lint issues
# golangci/golangci-lint:v2.12.2, 2026-05-06
# Pinned below v2.13.0 until the canonical .golangci.yml disables
# exhaustruct_v5 (see the TODO in README.md). Keep in sync with Makefile.
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
# Lint phase
# golangci/golangci-lint:v2.15.0, 2026-10-16
FROM golangci/golangci-lint@sha256:fbfcfd12a6f8d7f1b97b3362ca80fc2a244d46863148c73cb88e7793af96c481 AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN make fmt-check-go
RUN make lint
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Docs stage: markdown formatting check with the pinned prettier
# node:24-alpine, 2026-10-28
FROM node@sha256:8a32c8cf649a49915a66e81e7a609223c708d0adabf288ee3f4077d765542f85 AS docs
RUN apk add --no-cache make
# Test phase. -count=1 because the mounted cache also holds test
# results, which go test would otherwise replay. A first run that timed
# out gets no rerun, because a rerun would only wait for the same hang.
# golang:1.26-alpine, 2026-10-27
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS test
ENV CGO_ENABLED=0
WORKDIR /src
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
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
FROM golang@sha256:a2012b4a1d1f9f7e9f44e0d39b805a901e856b1dd80b4e6ee4338db547050308 AS builder
# gcc and musl-dev are needed only because `make test` uses -race, which
# requires cgo; the release binary below is built with CGO_ENABLED=0.
RUN apk add --no-cache make gcc musl-dev
# git comes from the base image's own distribution package index, which
# is trusted, so it needs no version and no hash.
RUN apk add --no-cache git
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 ./
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 . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /sutil ./cmd/sutil
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN --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
FROM alpine@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG CACHEBUST
COPY --from=builder /sutil /usr/local/bin/sutil
COPY --from=builder /src /usr/local/src/x
ENTRYPOINT ["sutil"]
+40 -90
View File
@@ -1,29 +1,46 @@
# sneak.berlin/go/x
#
# 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: bootstrap setup test lint fmt fmt-check check docker hooks \
build install tidy clean
.PHONY: all build install test lint fmt fmt-go fmt-md fmt-check \
fmt-check-go fmt-check-md check docker hooks tidy setup clean
# Makefile targets are thin shims; the implementations live in script/
# 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
VERSION := $(shell git describe --always --dirty 2>/dev/null || echo dev)
GOLDFLAGS := -X main.version=$(VERSION)
GO_DIRS := cmd internal pkg
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo unknown)
GOLDFLAGS := -X main.Version=$(VERSION)
# golangci/golangci-lint:v2.12.2, 2026-05-06
# Pinned below v2.13.0 until the canonical .golangci.yml disables
# exhaustruct_v5 (see the TODO in README.md). Keep in sync with Dockerfile.
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
# cgo stays off everywhere: builds are static, and the test phase of the
# Dockerfile runs without -race, which would need a C toolchain.
export CGO_ENABLED := 0
# Build the sutil binary in the repo root with the git version embedded.
build:
@@ -33,76 +50,9 @@ build:
install:
go install -trimpath -ldflags "$(GOLDFLAGS)" ./cmd/$(BINARY)
# Run the tests quietly; on failure rerun verbosely for diagnostics and
# 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 go.mod and go.sum; script/precommit refuses a commit that needs it.
tidy:
go mod tidy
# One-time developer setup: install the pinned prettier for markdown checks.
setup:
yarn install --frozen-lockfile
clean:
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](https://sneak.berlin) that serves as an umbrella publishing space for
small reusable packages, plus `sutil`, a command-line utility that exposes them
from the shell.
small reusable packages, plus `sutil`, a command-line utility holding a few
small tools worth having on every machine.
Importable packages live in `pkg/`. The CLI is `cmd/sutil/`, and the code that
exists only to support it lives in `internal/`.
@@ -39,18 +39,13 @@ sutil generate random --count 1073741824 "my seed" > testdata.bin
sutil generate password
# marble7-sprout2-velvet9
# A new 24-word BIP39 mnemonic phrase (or --words 12).
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.
# Build metadata, including which config file (if any) was loaded.
sutil version
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
@@ -71,30 +66,15 @@ func main() {
panic(err)
}
// The same mnemonic (and optional passphrase) always yields the same
// keypair, on any machine.
alice, err := cbk.New(cbk.Params{Mnemonic: mnemonic})
// The same mnemonic always yields the same keypair, on any machine.
// The keys are plain crypto_box keys for use with
// golang.org/x/crypto/nacl/box.
keypair, err := cbk.New(cbk.Params{Mnemonic: mnemonic})
if err != nil {
panic(err)
}
bob, err := cbk.FromMnemonic("abandon abandon abandon abandon abandon " +
"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())
fmt.Println(keypair.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
`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`
Derives NaCl crypto_box keypairs (Curve25519, XSalsa20, Poly1305)
deterministically from BIP39 mnemonics, and seals and opens messages with them.
The derivation is fixed and documented so keys are reproducible:
Derives NaCl crypto_box keypairs (Curve25519) deterministically from BIP39
mnemonics, and nothing else. The entropy comes from
[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
`bip39wordlist.NormalizeWordlist`.
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
the optional passphrase works as the usual "25th word".
3. The 32-byte private key is
`HKDF-SHA256(seed, salt = nil, info = cbk.DerivationInfo)`.
4. The public key is the Curve25519 base-point multiple of the private key.
rounds, salt `"mnemonic"`), exactly as a BIP39 wallet would with an empty
passphrase. BIP39 passphrases are deliberately not supported.
3. The seed becomes a BIP32 master key.
4. BIP85's HEX application derives 32 bytes of entropy at
`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
can be handled as a single opaque byte string. The API:
The resulting keys are plain 32-byte crypto_box keys for use with
`golang.org/x/crypto/nacl/box`. The API:
- `cbk.New(cbk.Params{Mnemonic, Passphrase})` and `cbk.FromMnemonic(m)` return a
`CryptoBoxKeypair`.
- `CryptoBoxKeypair.Seal(message, recipientPublicKey)` and
`CryptoBoxKeypair.Open(sealed, senderPublicKey)`.
- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, `cbk.KeyFromHex(s)`, and the
`PublicKeyHex`, `PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors
on the keypair.
- `cbk.New(cbk.Params{Mnemonic, Index})` and `cbk.FromMnemonic(m)` (index 0)
return a `CryptoBoxKeypair`.
- `CryptoBoxKey.Hex()`, `CryptoBoxKey.Bytes()`, and the `PublicKeyHex`,
`PrivateKeyHex`, `PublicKeyBytes`, `PrivateKeyBytes` accessors on the keypair.
The test suite pins the keypair derived from the standard all-zero-entropy BIP39
test vector. If that test ever fails, the derivation has changed and existing
keys are no longer reproducible.
The test suite checks the mnemonic-to-master-key step and the BIP85 HEX
application against the BIP85 specification's own test vectors, verifies that
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`
@@ -218,53 +207,89 @@ sutil
├── info print version, Go version, module, VCS metadata
├── generate (gen)
│ ├── random [seedstr] stream pseudorandom bytes (--count N to stop)
│ ├── password (pw) print a memorable password
│ └── mnemonic print a new BIP39 mnemonic (--words N)
└── cbk
└── derive keypair from a mnemonic on stdin (--public)
│ └── password (pw) print a memorable password
```
`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
The `Makefile` is the single entry point: `make test` (30-second timeout, race
detector, quiet unless something fails), `make lint`, `make fmt` and
`make fmt-check` (gofmt for Go, prettier for markdown), `make check` for all
three, `make build`, `make install`, `make docker`, `make hooks`, `make tidy`,
and `make setup` to install the pinned prettier.
The gates live in the `Dockerfile`, in the canonical Go layout that
`REPO_POLICIES.md` prescribes: a `lint` phase on the hash-pinned golangci-lint
image and a `test` phase on the Go image. `script/lint` and `script/test` each
build one phase alone, with a fresh `CACHEBUST` value so the check step runs on
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
`.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
hand here.
comment. `.golangci.yml` is byte-identical to the canonical copy in the
`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
```sh
make setup # once: install the pinned prettier with yarn
make check # test + lint + fmt-check, exactly what CI gates on
make setup # once: bootstrap and install the pre-commit hook
make check # test, lint, fmt-check: what the hook and CI run
make fmt # gofmt and prettier, writes files
make hooks # install the pre-commit hook that runs make check
make docker # the full CI build
make build # build ./sutil on the host
make docker # build the image tagged x
```
## TODO
- [ ] Tag `v0.1.0` once `main` is green in CI.
- [ ] 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.
Nothing outstanding.
## 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
// packages in sneak.berlin/go/x. Run `sutil --help` for the command list.
// Command sutil is sneak's shell utility: a few small tools worth having on
// every machine. Run `sutil --help` for the command list.
package main
import (
@@ -8,12 +8,14 @@ import (
"sneak.berlin/go/x/internal/cli"
)
// version is the build version. The Makefile and Dockerfile set it at link
// time with -ldflags "-X main.version=...".
var version = "dev"
// Version is the build version. The Makefile and Dockerfile set it at link
// time with -ldflags "-X main.Version=...".
//
//nolint:gochecknoglobals // set by the linker; there is no other way
var Version = "dev"
func main() {
err := cli.New(cli.Params{Version: version}).Execute()
err := cli.New(cli.Params{Version: Version, ConfigDir: ""}).Execute()
if err != nil {
os.Exit(1)
}
+17 -1
View File
@@ -3,16 +3,32 @@ module sneak.berlin/go/x
go 1.26.0
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/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
github.com/tyler-smith/go-bip39 v1.1.0
golang.org/x/crypto v0.58.0
sneak.berlin/go/secret v1.0.0-rc4
)
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/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
golang.org/x/sys v0.49.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/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/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/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/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/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.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/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/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.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.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/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
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.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/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/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 (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// Params configures New.
@@ -14,25 +15,48 @@ type Params struct {
// Version is the version string embedded at build time. It is shown
// by `sutil version`, `sutil --version`, and `sutil info`.
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.
func New(p Params) *cobra.Command {
a := &app{params: p, configFile: "", config: nil}
root := &cobra.Command{
Use: "sutil",
Short: "sneak's shell utility",
Long: `sutil is a small collection of shell utilities built on the packages in
sneak.berlin/go/x: fast pseudorandom byte streams, memorable passwords,
BIP39 mnemonics, and crypto_box keypairs derived from them.`,
Version: p.Version,
SilenceUsage: true,
Long: `sutil is a small collection of shell utilities worth having on every
machine: a fast pseudorandom byte stream and a memorable password generator.
Before any command runs, sutil loads the optional configuration file named
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(
newVersionCommand(p),
newInfoCommand(p),
newInfoCommand(a),
newGenerateCommand(),
newCBKCommand(),
)
return root
+82 -73
View File
@@ -3,6 +3,8 @@ package cli_test
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -11,29 +13,14 @@ import (
"sneak.berlin/go/x/internal/cli"
"sneak.berlin/go/x/internal/pwgen"
"sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk"
)
const (
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"
}
const testVersion = "v1.2.3-test"
func TestVersionCommand(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "version")
output, err := execute(t, "version")
require.NoError(t, err)
assert.Equal(t, testVersion+"\n", output)
}
@@ -41,7 +28,7 @@ func TestVersionCommand(t *testing.T) {
func TestVersionFlag(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "--version")
output, err := execute(t, "--version")
require.NoError(t, err)
assert.Contains(t, output, testVersion)
}
@@ -49,25 +36,75 @@ func TestVersionFlag(t *testing.T) {
func TestInfoCommand(t *testing.T) {
t.Parallel()
output, err := execute(t, "", "info")
output, err := execute(t, "info")
require.NoError(t, err)
assert.Contains(t, output, "version: "+testVersion+"\n")
assert.Contains(t, output, "go: go")
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) {
t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "4096", "seed")
first, err := execute(t, "generate", "random", "--count", "4096", "seed")
require.NoError(t, err)
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)
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)
assert.NotEqual(t, first, other)
}
@@ -75,11 +112,11 @@ func TestGenerateRandomSeededIsDeterministic(t *testing.T) {
func TestGenerateRandomUnseeded(t *testing.T) {
t.Parallel()
first, err := execute(t, "", "generate", "random", "--count", "1024")
first, err := execute(t, "generate", "random", "--count", "1024")
require.NoError(t, err)
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)
assert.NotEqual(t, first, second)
}
@@ -87,28 +124,10 @@ func TestGenerateRandomUnseeded(t *testing.T) {
func TestGenerateRandomRejectsExtraArguments(t *testing.T) {
t.Parallel()
_, err := execute(t, "", "generate", "random", "one", "two")
_, err := execute(t, "generate", "random", "one", "two")
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) {
t.Parallel()
@@ -119,7 +138,7 @@ func TestGeneratePassword(t *testing.T) {
require.NoError(t, err)
output, err := execute(t, "", "generate", "password")
output, err := execute(t, "generate", "password")
require.NoError(t, err)
password := strings.TrimSuffix(output, "\n")
@@ -129,46 +148,36 @@ func TestGeneratePassword(t *testing.T) {
assert.True(t, strings.ContainsAny(password, pwgen.Delimiters), password)
}
func TestCBKDerive(t *testing.T) {
func TestUnknownCommandFails(t *testing.T) {
t.Parallel()
expected, err := cbk.FromMnemonic(vectorMnemonic())
require.NoError(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)
_, err := execute(t, "cbk")
require.Error(t, err)
}
func TestCBKDeriveRejectsInvalidMnemonic(t *testing.T) {
t.Parallel()
_, err := execute(t, "not a mnemonic\n", "cbk", "derive")
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) {
// execute runs sutil with the given arguments, looking for config in an
// empty temporary directory, and returns everything it wrote to stdout and
// stderr.
func execute(t *testing.T, args ...string) (string, error) {
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
root.SetOut(&output)
root.SetErr(&output)
root.SetIn(strings.NewReader(stdin))
root.SetArgs(args)
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/randstream"
"sneak.berlin/go/x/pkg/bip39wordlist"
)
// streamChunkSize is the write size used by `generate random`. Large
@@ -21,13 +20,12 @@ func newGenerateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
Aliases: []string{"gen"},
Short: "Generate random bytes, passwords, and mnemonics",
Short: "Generate random bytes and passwords",
}
cmd.AddCommand(
newGenerateRandomCommand(),
newGeneratePasswordCommand(),
newGenerateMnemonicCommand(),
)
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
// src to dst in streamChunkSize chunks.
func streamBytes(dst io.Writer, src io.Reader, limit int64) error {
+15 -5
View File
@@ -15,31 +15,41 @@ import (
// module support.
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
// Go toolchain version, the module path, and the VCS metadata recorded by
// the Go toolchain at build time.
func newInfoCommand(p Params) *cobra.Command {
// Go toolchain version, the module path, the configuration file in use,
// and the VCS metadata recorded by the Go toolchain at build time.
func newInfoCommand(a *app) *cobra.Command {
return &cobra.Command{
Use: "info",
Short: "Print build information",
Args: cobra.NoArgs,
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.
func writeBuildInfo(w io.Writer, version string) error {
func writeBuildInfo(w io.Writer, version, configFile string) error {
info, ok := debug.ReadBuildInfo()
if !ok {
return ErrNoBuildInfo
}
if configFile == "" {
configFile = noConfigFile
}
lines := []string{
"version: " + version,
"go: " + info.GoVersion,
"module: " + info.Main.Path,
"config: " + configFile,
}
for _, setting := range info.Settings {
+57 -124
View File
@@ -1,98 +1,60 @@
// Package cbk derives NaCl crypto_box keypairs (Curve25519, XSalsa20,
// Poly1305) deterministically from BIP39 mnemonic phrases, and seals and
// opens messages with them.
// Package cbk derives NaCl crypto_box keypairs (Curve25519) deterministically
// from BIP39 mnemonic phrases using BIP85. That is all it does; the
// resulting keys are plain 32-byte crypto_box keys for use with
// golang.org/x/crypto/nacl/box.
//
// # Key derivation
//
// The same mnemonic and passphrase always produce the same keypair:
// The same mnemonic and index always produce the same keypair:
//
// 1. The mnemonic is validated and canonicalized with
// bip39wordlist.NormalizeWordlist (lowercase, single spaces).
// 2. The standard 64-byte BIP39 seed is computed from the canonical
// mnemonic and the optional passphrase with PBKDF2-HMAC-SHA512 (2048
// rounds, salt "mnemonic" + passphrase), exactly as a BIP39 wallet
// would.
// 3. The 32-byte crypto_box private key is derived from that seed with
// HKDF-SHA256, using no salt and DerivationInfo as the info string.
// 4. The public key is the Curve25519 scalar multiplication of the
// mnemonic with PBKDF2-HMAC-SHA512 (2048 rounds, salt "mnemonic"),
// exactly as a wallet would with an empty passphrase. BIP39
// passphrases are deliberately not supported.
// 3. The seed becomes a BIP32 master key.
// 4. BIP85 (sneak.berlin/go/secret/pkg/bip85) derives 32 bytes of
// 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.
//
// # Sealing
//
// Seal and Open wrap box.Seal and box.Open. A sealed message is the
// 24-byte random nonce followed by the box ciphertext, so it can be stored
// or transmitted as a single opaque byte string.
// Because the entropy comes from BIP85, any BIP85-capable tool given the
// same mnemonic reproduces the private key, and the index lets one
// mnemonic back any number of independent keypairs.
package cbk
import (
"bytes"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/tyler-smith/go-bip39"
"golang.org/x/crypto/nacl/box"
"sneak.berlin/go/secret/pkg/bip85"
"sneak.berlin/go/x/pkg/bip39wordlist"
)
const (
// 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
// NonceSize is the length in bytes of the nonce that prefixes every
// sealed message.
NonceSize = 24
// 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
// MaxIndex is the largest valid Params.Index. BIP85 path components
// are hardened BIP32 child indexes, which leaves 31 bits.
MaxIndex = hdkeychain.HardenedKeyStart - 1
)
var (
// ErrInvalidKeyHex is returned by KeyFromHex for input that is not
// 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")
)
// ErrIndexTooLarge is returned by New when Params.Index exceeds MaxIndex.
var ErrIndexTooLarge = errors.New("cbk: index exceeds the BIP32 hardened range")
// CryptoBoxKey is a 32-byte crypto_box key, either public or private.
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.
func (k CryptoBoxKey) Bytes() []byte {
return k[:]
@@ -109,10 +71,10 @@ type Params struct {
// whitespace between words is accepted; it is normalized before use.
Mnemonic string
// Passphrase is the optional BIP39 passphrase (sometimes called the
// "25th word"). It is used verbatim: it is case sensitive and is not
// trimmed. An empty passphrase is the common case.
Passphrase string
// Index selects which of the keypairs derivable from the mnemonic to
// return. It is the final component of the BIP85 derivation path, so
// each index yields an independent keypair. Zero is the first.
Index uint32
}
// CryptoBoxKeypair is a crypto_box public and private key pair.
@@ -121,20 +83,37 @@ type CryptoBoxKeypair struct {
PrivateKey CryptoBoxKey
}
// New derives the keypair for the mnemonic and passphrase in p. If the
// mnemonic is not valid it returns an error wrapping one of the
// bip39wordlist sentinel errors.
// New derives the keypair for the mnemonic and index in p. If the mnemonic
// is not valid it returns an error wrapping one of the bip39wordlist
// sentinel errors.
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)
if err != nil {
return CryptoBoxKeypair{}, err
}
seed := bip39.NewSeed(mnemonic, p.Passphrase)
privateKeyBytes, err := hkdf.Key(sha256.New, seed, nil, DerivationInfo, KeySize)
// The seed is always computed with an empty BIP39 passphrase. The
// network parameters only choose the version bytes used when a key is
// serialized; they do not affect derivation.
masterKey, err := hdkeychain.NewMaster(
bip39.NewSeed(mnemonic, ""), &chaincfg.MainNetParams,
)
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))
@@ -148,10 +127,10 @@ func New(p Params) (CryptoBoxKeypair, error) {
}, nil
}
// FromMnemonic derives the keypair for mnemonic with an empty passphrase.
// It is shorthand for New(Params{Mnemonic: mnemonic}).
// FromMnemonic derives the keypair for mnemonic at index zero. It is
// shorthand for New(Params{Mnemonic: mnemonic}).
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.
@@ -175,49 +154,3 @@ func (kp CryptoBoxKeypair) PublicKeyHex() string {
func (kp CryptoBoxKeypair) PrivateKeyHex() string {
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"
"testing"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/stretchr/testify/assert"
"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/cbk"
)
// vectorRepeats is how many times "abandon" appears in the standard BIP39
// test vector for all-zero 128-bit entropy, which ends with "about".
const vectorRepeats = bip39wordlist.MinWordCount - 1
// 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.
// The BIP85 specification publishes its test vectors against one master
// key. specMnemonic is the BIP39 phrase that produces that master key, so
// the whole mnemonic-to-entropy chain this package relies on can be
// checked against the specification.
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
// 128-bit entropy.
func vectorMnemonic() string {
return strings.Repeat("abandon ", vectorRepeats) + "about"
// The keys below are the keypair for specMnemonic at index 0, pinned when
// the BIP85 derivation was implemented. If this test starts failing, the
// derivation has changed and existing keys are no longer reproducible.
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) {
t.Parallel()
keypair, err := cbk.FromMnemonic(vectorMnemonic())
keypair, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err)
assert.Equal(t, vectorPublicKeyHex, keypair.PublicKeyHex())
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) {
t.Parallel()
canonical, err := cbk.FromMnemonic(vectorMnemonic())
canonical, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err)
messy, err := cbk.New(cbk.Params{
Mnemonic: " ABANDON\tAbandon " + strings.Repeat("abandon ", 9) +
"\nabout\n",
Passphrase: "",
})
words := strings.Fields(specMnemonic)
messy := " " + strings.ToUpper(words[0]) + "\t" +
strings.Join(words[1:], " ") + "\n"
normalized, err := cbk.New(cbk.Params{Mnemonic: messy, Index: 0})
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()
plain, err := cbk.FromMnemonic(vectorMnemonic())
other, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
require.NoError(t, err)
extraWord := "TREZOR"
withExtraWord, err := cbk.New(cbk.Params{
Mnemonic: vectorMnemonic(),
Passphrase: extraWord,
})
specKeypair, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err)
assert.NotEqual(t, plain.PublicKey, withExtraWord.PublicKey)
assert.NotEqual(t, plain.PrivateKey, withExtraWord.PrivateKey)
again, err := cbk.New(cbk.Params{
Mnemonic: vectorMnemonic(),
Passphrase: extraWord,
})
otherKeypair, err := cbk.FromMnemonic(other)
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) {
@@ -89,14 +146,17 @@ func TestNewRejectsInvalidMnemonic(t *testing.T) {
_, err := cbk.FromMnemonic("abandon bogus about")
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)
}
func TestKeyAccessors(t *testing.T) {
t.Parallel()
keypair, err := cbk.FromMnemonic(vectorMnemonic())
keypair, err := cbk.FromMnemonic(specMnemonic)
require.NoError(t, err)
assert.Equal(t, keypair.PublicKey[:], keypair.PublicKeyBytes())
@@ -105,99 +165,18 @@ func TestKeyAccessors(t *testing.T) {
assert.Len(t, keypair.PrivateKeyBytes(), cbk.KeySize)
assert.Equal(t, keypair.PublicKey.Hex(), keypair.PublicKeyHex())
assert.Equal(t, keypair.PrivateKey.Hex(), keypair.PrivateKeyHex())
assert.Len(t, keypair.PublicKeyHex(), 2*cbk.KeySize)
}
func TestKeyFromHexRoundTrip(t *testing.T) {
t.Parallel()
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 {
// specMasterKeyFromMnemonic builds the BIP32 master key from specMnemonic
// the same way the package does, independently of it.
func specMasterKeyFromMnemonic(t *testing.T) *hdkeychain.ExtendedKey {
t.Helper()
mnemonic, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
seed := bip39.NewSeed(specMnemonic, "")
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
require.NoError(t, err)
keypair, err := cbk.FromMnemonic(mnemonic)
require.NoError(t, err)
return keypair
return masterKey
}
+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 "$@"