Files
x/README.md
T

291 lines
11 KiB
Markdown

# x
## Description
`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.
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
```sh
go install sneak.berlin/go/x/cmd/sutil@latest
```
Or, from a checkout:
```sh
make build # builds ./sutil with the git version embedded
make install # installs sutil into $(go env GOBIN)
```
### Use the CLI
```sh
# 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
# 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, 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
```go
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 (and optional passphrase) always yields the same
// keypair, on any machine.
alice, 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())
}
```
## 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/urandom` is 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 random` streams 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/cbk` makes 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, XSalsa20, Poly1305)
deterministically from BIP39 mnemonics, and seals and opens messages with them.
The derivation is fixed and documented so keys are reproducible:
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.
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:
- `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.
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.
### `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`, `ErrUnknownWord` naming 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 from `crypto/rand`.
- `Contains(word)`, `ValidWordCount(n)`, and the `WordlistEnglish` and
`Wordlist` tables.
### `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
│ └── mnemonic print a new BIP39 mnemonic (--words N)
└── cbk
└── derive keypair from a mnemonic on stdin (--public)
```
### 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. 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. 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
```sh
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
- [ ] Bump the pinned `golangci/golangci-lint` image (in `Makefile` and
`Dockerfile`) past v2.12.2 once the canonical `.golangci.yml` disables
`exhaustruct_v5`, which v2.13.0 added alongside the already-disabled
`exhaustruct`, and replaces the deprecated `gomodguard` with
`gomodguard_v2`. With `default: all`, newer images enable `exhaustruct_v5`
and fail every partial struct literal.
- [ ] Add `sutil cbk seal` and `sutil cbk open` so messages can be sealed and
opened from the shell, not just from Go.
- [ ] Let `sutil cbk derive` take the BIP39 passphrase from an environment
variable; the library supports it, the CLI does not expose it yet.
## License
WTFPL. See `LICENSE`.
## Author
[@sneak](https://sneak.berlin)
&lt;[sneak@sneak.berlin](mailto:sneak@sneak.berlin)&gt;