diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..8af31cd --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 4, + "proseWrap": "always" +} diff --git a/README.md b/README.md index 8ee721b..76dec60 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,8 @@ make build ``` `make build` produces `./keyfunc`. Every deriving command needs a mnemonic; see -[Giving it the mnemonic](#giving-it-the-mnemonic) for where it is read from, then -for example: +[Giving it the mnemonic](#giving-it-the-mnemonic) for where it is read from, +then for example: ``` ./keyfunc ssh pub -n 0 --mnemonic-command 'secret get foo' @@ -105,11 +105,12 @@ The mnemonic itself is never a command-line argument. It is looked for in this order; the first one found wins: 1. `--mnemonic-command `: a shell command, run with `sh -c`, whose - standard output is the mnemonic. Example: `--mnemonic-command 'secret get - foo'`. Whitespace around the output is dropped. If the command exits with a - non-zero status, the tool prints its standard error and exits with status 1. -2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell - command held in the environment. + standard output is the mnemonic. Example: + `--mnemonic-command 'secret get foo'`. Whitespace around the output is + dropped. If the command exits with a non-zero status, the tool prints its + standard error and exits with status 1. +2. Environment variable `KEYFUNC_MNEMONIC_COMMAND`: the same, as a shell command + held in the environment. 3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself. 4. A prompt on the terminal with echo turned off. @@ -119,8 +120,7 @@ refused with a message saying so. `KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the environment before the system `ssh` (`keyfunc ssh to`) and `sftp` -(`keyfunc ssh install`) are started, so the mnemonic is never handed on to -them. +(`keyfunc ssh install`) are started, so the mnemonic is never handed on to them. Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`. `keyfunc --version` prints the version. `make build` stamps it; a binary @@ -128,9 +128,8 @@ installed with `go install` reports the module version instead. ## SSH keys: `keyfunc ssh` -Only ed25519 keys are produced. The application number is `838372`, so the -path is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 -seed. +Only ed25519 keys are produced. The application number is `838372`, so the path +is `m/83696968'/838372'/'`. The 32 bytes from step 4 are the ed25519 seed. Test vector, mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: @@ -160,24 +159,24 @@ same as for `pub`. ### `keyfunc ssh install <[user@]host> [-- sftp options...]` Adds the `pub` line to `~/.ssh/authorized_keys` on the host. No command is run -on the host: the file is fetched, changed here, and written back with the -system `sftp` client in batch mode. +on the host: the file is fetched, changed here, and written back with the system +`sftp` client in batch mode. -The first connection lists `~/.ssh` and then fetches -`~/.ssh/authorized_keys` from it. The file reads as empty in two cases only: -`sftp` reported `~/.ssh` itself as not being there, or the listing came up and -the file was not in it. Any other outcome of that connection fails the run — a -`~/.ssh` that is there but cannot be entered, an `authorized_keys` that is there -but cannot be read, or a connection that did not come up — and the tool prints -what `sftp` said and exits with status 1 without writing anything, rather than -put a file back holding the new key alone. The listing is what tells a missing -directory from one shut to the user, which `sftp` reports on a fetch the same -way; the wording of a missing file elsewhere does not count either, since `ssh` -writes `No such file or directory` about an `-i` it cannot find on a session -that then authenticates through the agent. If an identical line is already in -the file, the tool prints `already present` and connects no further. Otherwise -the line is added (after a newline, if the file did not end with one) and a -second connection: +The first connection lists `~/.ssh` and then fetches `~/.ssh/authorized_keys` +from it. The file reads as empty in two cases only: `sftp` reported `~/.ssh` +itself as not being there, or the listing came up and the file was not in it. +Any other outcome of that connection fails the run — a `~/.ssh` that is there +but cannot be entered, an `authorized_keys` that is there but cannot be read, or +a connection that did not come up — and the tool prints what `sftp` said and +exits with status 1 without writing anything, rather than put a file back +holding the new key alone. The listing is what tells a missing directory from +one shut to the user, which `sftp` reports on a fetch the same way; the wording +of a missing file elsewhere does not count either, since `ssh` writes +`No such file or directory` about an `-i` it cannot find on a session that then +authenticates through the agent. If an identical line is already in the file, +the tool prints `already present` and connects no further. Otherwise the line is +added (after a newline, if the file did not end with one) and a second +connection: - makes `~/.ssh` and sets it to mode `0700`, but only when the first connection found none; a `~/.ssh` that was already there keeps the mode it had; @@ -188,15 +187,14 @@ second connection: The tool then prints `added`. So a run that adds a line connects twice. The rename is the step that either happens or does not: the file on the host is never half-written. `sftp` does it in one step against servers that offer -OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server -without it may refuse to rename onto a file that is already there. +OpenSSH's POSIX rename extension, as OpenSSH's own server does; a server without +it may refuse to rename onto a file that is already there. If a step fails, the tool prints what `sftp` said, removes nothing, and exits -with status 1. It names the uploaded file only when the step that failed was -the upload or one after it, which is where a file of that name can be on the -host; a failure before the upload names none. Everything `sftp` -writes goes to standard error, so the tool's own standard output is only -`added` or `already present`. +with status 1. It names the uploaded file only when the step that failed was the +upload or one after it, which is where a file of that name can be on the host; a +failure before the upload names none. Everything `sftp` writes goes to standard +error, so the tool's own standard output is only `added` or `already present`. Anything after `--` is passed to `sftp` unchanged, which is where the port goes (`-P 2222`, not `-p`). How the connection authenticates is up to the user's @@ -216,10 +214,10 @@ and the tool then exits with status 1 unless `ssh` reported one of its own. ## age identities: `keyfunc age` The application number is `657169`, path `m/83696968'/657169'/'`. The 32 -bytes from step 4 are clamped as X25519 requires and become an age identity, -the same steps `sneak/secret` takes in its `agehd` package. `secret` derives at -a vendor-specific path today; for its keys to equal this tool's it moves to -this path, which is a change in `secret`, not here. +bytes from step 4 are clamped as X25519 requires and become an age identity, the +same steps `sneak/secret` takes in its `agehd` package. `secret` derives at a +vendor-specific path today; for its keys to equal this tool's it moves to this +path, which is a change in `secret`, not here. Test vectors, mnemonic `abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about`: @@ -256,11 +254,11 @@ says so and exits with status 1. ### `keyfunc mnemonic [-n N] [--words 12|18|24]` Prints a child mnemonic derived from the main one, using BIP-85's own mnemonic -application (number `39`, English, path -`m/83696968'/39'/0'/'/'`, entropy taken as the specification says, -not through step 4). Default 12 words. A child mnemonic is a full mnemonic in -its own right: it can seed another `keyfunc`, another wallet, or `secret`, and -it never has to be written down, since it can be derived again. +application (number `39`, English, path `m/83696968'/39'/0'/'/'`, +entropy taken as the specification says, not through step 4). Default 12 words. +A child mnemonic is a full mnemonic in its own right: it can seed another +`keyfunc`, another wallet, or `secret`, and it never has to be written down, +since it can be derived again. Test vector: the child-mnemonic step is checked against BIP-85's own published vectors, which derive from the specification's master key @@ -273,19 +271,21 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose ## Errors -Errors go to standard error and the exit status is 1, except for `ssh to`, -which passes through `ssh`'s own exit status. +Errors go to standard error and the exit status is 1, except for `ssh to`, which +passes through `ssh`'s own exit status. ## Entrypoints The repo adheres to the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) -standard: most Makefile targets are thin shims over an executable in -`script/` (`build` and `clean` are the exceptions). +standard: most Makefile targets are thin shims over an executable in `script/` +(`build` and `clean` are the exceptions). - `script/bootstrap` installs everything needed to build and develop (git, make, - Go), idempotently, from nix, apt, brew or apk; it does not install the linter, - which only runs inside Docker. + Go, node and yarn), idempotently, from nix, apt, brew or apk, with node at a + pinned version through nvm when it is not already installed; it then installs + the pinned prettier with yarn. It does not install the linter, which only runs + inside Docker. - `script/setup` prepares a fresh clone: it runs `bootstrap`, then installs the git pre-commit hook. - `script/projectname` prints the project name; other scripts call it so they @@ -296,9 +296,11 @@ standard: most Makefile targets are thin shims over an executable in - `script/lint` builds the `lint` phase of the `Dockerfile` alone, uncached: the linter, pinned by hash, runs inside the build, so a complaint fails it and leaves no container behind. -- `script/fmt` formats the Go source in place. -- `script/fmt-check` checks that formatting without writing, failing if anything - is unformatted. +- `script/fmt` formats in place: the Go source with `go fmt`, then every + Markdown file with prettier (four-space indents, prose wrapped at 80 columns). +- `script/fmt-check` checks the same files the same way without writing, failing + if anything is unformatted. Both need the node and yarn that `bootstrap` + installs. - `script/check` runs `test`, `lint` and `fmt-check` and changes no files. - `script/docker` builds the Docker image, uncached, tagged with the project name and stamped with the version `git describe` gives on the host. The image @@ -315,7 +317,6 @@ standard: most Makefile targets are thin shims over an executable in The open issues that stand between the tree and a 1.0 release: -- [#39 make fmt and make fmt-check cover Markdown with prettier](https://git.eeqj.de/sneak/keyfunc/issues/39) - [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42) ## License diff --git a/package.json b/package.json new file mode 100644 index 0000000..dc05cde --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "prettier": "3.8.1" + } +} diff --git a/script/bootstrap b/script/bootstrap index 4705d2b..f2f10b7 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,13 +3,23 @@ # repo. Idempotent: every install is guarded by a check, so tools that # are already there are left alone. Base tooling comes from nix, apt, # brew, or apk, detected in that order, and nothing is assumed to be -# present. The linter is not installed here: linting and testing run -# only as phases of the Dockerfile, so Docker is what is needed for -# them, and that is checked for rather than installed. +# present. Node is used directly if installed; otherwise it is installed +# at a pinned version via nvm (installing nvm itself first, from a +# hash-verified release archive, never curl | sh). The linter is not +# installed here: linting and testing run only as phases of the +# Dockerfile, so Docker is what is needed for them, and that is checked +# for rather than installed. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Pinned versions, 2026-07-06 +NODE_VERSION="22.17.0" +NVM_VERSION="0.40.3" +# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz +NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0" +YARN_VERSION="1.22.22" + PKGMGR="" SUDO="" @@ -50,6 +60,69 @@ missing() { ! command -v "$1" >/dev/null 2>&1 } +# verify_sha256 +verify_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$1" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$1" | cut -d' ' -f1)" + fi + if [ "$actual" != "$2" ]; then + echo "bootstrap: sha256 mismatch for $1" >&2 + echo " expected: $2" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +# nvm is a bash script; run a command in a bash with nvm loaded +nvm_sh() { + bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*" +} + +ensure_nvm() { + [ -s "$HOME/.nvm/nvm.sh" ] && return 0 + # nvm prerequisites; nvm itself requires bash + if missing bash; then pkg_install bash bash bash bash; fi + if missing curl; then pkg_install curl curl curl curl; fi + if missing git; then pkg_install git git git git; fi + tmp="$(mktemp -d)" + curl -fsSL -o "$tmp/nvm.tar.gz" \ + "https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz" + verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256" + mkdir -p "$HOME/.nvm" + tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1 + rm -rf "$tmp" +} + +ensure_node() { + if ! missing node; then return 0; fi + ensure_nvm + nvm_sh "nvm install $NODE_VERSION" +} + +ensure_yarn() { + if ! missing yarn; then return 0; fi + if ! missing corepack; then + corepack enable + corepack prepare "yarn@$YARN_VERSION" --activate + elif [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \ + corepack prepare yarn@$YARN_VERSION --activate" + else + npm install -g "yarn@$YARN_VERSION" + fi +} + +install_js_deps() { + if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then + nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \ + yarn install --frozen-lockfile" + else + yarn install --frozen-lockfile + fi +} + main() { cd "$ROOT" @@ -59,6 +132,10 @@ main() { go mod download + ensure_node + ensure_yarn + install_js_deps + if missing docker; then echo "bootstrap: docker is not installed; make lint and make test need it" >&2 fi diff --git a/script/fmt b/script/fmt index e95d111..3aaed25 100755 --- a/script/fmt +++ b/script/fmt @@ -1,12 +1,33 @@ #!/bin/sh -# script/fmt: format all files (writes). +# script/fmt: format all files (writes): the Go source with go fmt, then +# the Markdown files with prettier. set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" go fmt ./... + run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/script/fmt-check b/script/fmt-check index 2e91588..ddbba8b 100755 --- a/script/fmt-check +++ b/script/fmt-check @@ -5,6 +5,25 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# Must match the pin in script/bootstrap. +NODE_VERSION="22.17.0" + +# script/bootstrap installs node and yarn under nvm and leaves neither +# on the PATH of the shell that called it, so resolve the pinned +# toolchain here the way bootstrap's own install step does. nvm is a +# bash script, hence the subshell. +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + exec yarn "$@" + fi + if [ ! -s "$HOME/.nvm/nvm.sh" ]; then + echo "fmt-check: no yarn; run script/bootstrap first" >&2 + exit 1 + fi + exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null && + shift && exec yarn "$@"' bash "$NODE_VERSION" "$@" +} + main() { cd "$ROOT" if [ -n "$(gofmt -l .)" ]; then @@ -12,6 +31,7 @@ main() { gofmt -l . exit 1 fi + run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always } main "$@" diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..d846639 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +prettier@3.8.1: + version "3.8.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" + integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==