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.
10 KiB
x
Description
sneak.berlin/go/x is a WTFPL-licensed Go module and CLI tool by
@sneak that serves as an umbrella publishing space for
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/.
Getting Started
Install the CLI
go install sneak.berlin/go/x/cmd/sutil@latest
Or, from a checkout:
make build # builds ./sutil with the git version embedded
make install # installs sutil into $(go env GOBIN)
Use the CLI
# An endless stream of fast pseudorandom bytes; stop it with Ctrl-C.
sutil generate random | pv > /dev/null
# The same, but deterministic: the same seed always produces the same bytes.
sutil generate random --count 1073741824 "my seed" > testdata.bin
# A memorable password: three dictionary words, each followed by a digit,
# joined by a randomly chosen delimiter. Requires a system word list.
sutil generate password
# marble7-sprout2-velvet9
# 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
package main
import (
"fmt"
"sneak.berlin/go/x/pkg/bip39wordlist"
"sneak.berlin/go/x/pkg/cbk"
)
func main() {
mnemonic, err := bip39wordlist.GenerateMnemonic(bip39wordlist.MaxWordCount)
if err != nil {
panic(err)
}
// 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)
}
fmt.Println(keypair.PublicKeyHex())
}
Rationale
- Reusable odds and ends need a home. Several small packages are worth sharing between projects but are too small to each deserve a repository, CI pipeline, and release cadence of their own. This module collects them under one import path.
/dev/urandomis slow. Operating system random devices are tuned for small requests of key material, not for filling a disk or producing gigabytes of test data.sutil generate randomstreams pseudorandom bytes at close to memory bandwidth, and can do so deterministically from a seed so test data is reproducible.- Keys you can carry in your head. A crypto_box keypair derived from a BIP39
mnemonic can be regenerated anywhere from a phrase a human can write down or
memorize, with no key file to lose.
pkg/cbkmakes that derivation explicit and reproducible. - Passwords you can type. Three dictionary words with digits and a delimiter are far easier to transcribe than line noise, and with the macOS word list they still carry about 62 bits of entropy.
Design
Layout
| Path | Purpose |
|---|---|
cmd/sutil/ |
package main; the whole entrypoint is a few lines |
internal/cli/ |
The cobra command tree, built by cli.New |
internal/randstream/ |
Fast seeded pseudorandom byte stream |
internal/pwgen/ |
Password generator over the system dictionary |
pkg/bip39wordlist/ |
BIP39 English wordlist and mnemonic helpers |
pkg/cbk/ |
crypto_box keypairs derived from BIP39 mnemonics |
The split between pkg/ and internal/ is deliberate and should be kept:
pkg/ holds only packages designed to be imported by other programs, with a
stable, documented API. Anything that exists to support sutil goes under
internal/, even if it would be harmless to export, so that the public surface
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. 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) deterministically from BIP39
mnemonics, and nothing else. The entropy comes from
BIP85 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:
- The mnemonic is validated and canonicalized with
bip39wordlist.NormalizeWordlist. - The standard 64-byte BIP39 seed is computed with PBKDF2-HMAC-SHA512 (2048
rounds, salt
"mnemonic"), exactly as a BIP39 wallet would with an empty passphrase. BIP39 passphrases are deliberately not supported. - The seed becomes a BIP32 master key.
- BIP85's HEX application derives 32 bytes of entropy at
m/83696968'/128169'/32'/{index}'; those bytes are the private key. - The public key is the Curve25519 base-point multiple of the private key.
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, Index})andcbk.FromMnemonic(m)(index 0) return aCryptoBoxKeypair.CryptoBoxKey.Hex(),CryptoBoxKey.Bytes(), and thePublicKeyHex,PrivateKeyHex,PublicKeyBytes,PrivateKeyBytesaccessors on the keypair.
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
The BIP39 English wordlist, embedded from english.txt and checked against the
reference implementation in the tests, plus mnemonic helpers:
ValidateWordlist(mnemonic)checks word count, every word, and the checksum, ignoring letter case and whitespace. It reports what is wrong with sentinel errors (ErrInvalidWordCount,ErrUnknownWordnaming the word and its position,ErrInvalidChecksum).NormalizeWordlist(mnemonic)returns the canonical lowercase, single-space-separated form, and refuses anything invalid.GenerateMnemonic(wordCount)makes a new mnemonic of 12, 15, 18, 21, or 24 words fromcrypto/rand.Contains(word),ValidWordCount(n), and theWordlistEnglishandWordlisttables.
internal/randstream
An io.Reader that expands a 32-byte key with the ChaCha8 generator from
math/rand/v2. With a seed string the key is its SHA-256 and the stream is
deterministic; without one the key comes from crypto/rand, which is the only
time the operating system's random source is touched. The output is for bulk
data, not for cryptographic keys.
internal/pwgen
Produces passwords of the form
<word><digit><delim><word><digit><delim><word><digit>
where the delimiter is drawn once per password from .-_|~+=:,!@#%^&* and used
in both positions. Words come from the system word list
(/usr/share/dict/words) through github.com/kavfixnel/words, filtered to
lowercase ASCII words of four to eight letters, which drops proper nouns and
abbreviations. All choices use crypto/rand. On a system without a word list,
sutil generate password panics rather than falling back to something weaker;
the upstream library is only known to work on macOS.
CLI
sutil
├── version print the build version
├── 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
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, 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.
make lint runs golangci-lint inside the same hash-pinned Docker image the CI
build uses, so local results match CI exactly, and first verifies
.golangci.yml against the linter's schema, because golangci-lint otherwise
ignores keys it does not know and silently runs with defaults. 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 this repo's copy of the standard configuration, kept
in the v2 layout with exhaustruct_v5 and the deprecated gomodguard disabled.
cgo stays off: the Makefile exports CGO_ENABLED=0 for every build and test, so
binaries are static and no C toolchain is needed anywhere.
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.
Development
make setup # once: install the pinned prettier with yarn
make check # test + lint + fmt-check, exactly what CI gates on
make fmt # gofmt and prettier, writes files
make hooks # install the pre-commit hook that runs make check
make docker # the full CI build
TODO
Nothing outstanding.
License
WTFPL. See LICENSE.