Skeleton, mnemonic input, derivation, and the ssh commands #5

Merged
clawbot merged 2 commits from issue-1-skeleton into next 2026-09-07 17:34:54 +02:00
Collaborator

Closes #1

keyfunc ssh pub and keyfunc ssh priv derive an ed25519 key from a BIP-39 mnemonic and print it, as an authorized_keys line or as an unencrypted OpenSSH private key. The tests assert both README test vectors end to end, the private key parsing back to the public one, the order the mnemonic sources are tried in, and the refusal of a mnemonic that fails its checksum.

main.go sits at the root; internal/mnemonic finds the mnemonic, internal/derive does the path and the bytes, internal/sshkey makes the key, and internal/cli holds the command tree with one package per key type below it, so adding a type is one package and one subcommand.

What the diff does not show:

  • Linting runs only inside the image built from Dockerfile.lint, which pins the linter by hash. A build step cannot start Docker, so the root Dockerfile runs the formatting check, the tests and the build, and script/cibuild runs the linter before it. This departs from the single-Dockerfile lint stage described in REPO_POLICIES.md.
  • go.mod asks for Go 1.26 because the pinned linter is built with 1.26.2.
  • Two nolint comments: the mnemonic command is meant to be an arbitrary shell command, and the version variable is set at build time.
  • README.md needed no change; make build and make check do what it says they do.
  • There is no LICENSE file yet; that choice is yours.

make check is green.

Model: opus-5

Closes https://git.eeqj.de/sneak/keyfunc/issues/1 `keyfunc ssh pub` and `keyfunc ssh priv` derive an ed25519 key from a BIP-39 mnemonic and print it, as an `authorized_keys` line or as an unencrypted OpenSSH private key. The tests assert both README test vectors end to end, the private key parsing back to the public one, the order the mnemonic sources are tried in, and the refusal of a mnemonic that fails its checksum. `main.go` sits at the root; `internal/mnemonic` finds the mnemonic, `internal/derive` does the path and the bytes, `internal/sshkey` makes the key, and `internal/cli` holds the command tree with one package per key type below it, so adding a type is one package and one subcommand. What the diff does not show: - Linting runs only inside the image built from `Dockerfile.lint`, which pins the linter by hash. A build step cannot start Docker, so the root `Dockerfile` runs the formatting check, the tests and the build, and `script/cibuild` runs the linter before it. This departs from the single-Dockerfile lint stage described in `REPO_POLICIES.md`. - `go.mod` asks for Go 1.26 because the pinned linter is built with 1.26.2. - Two `nolint` comments: the mnemonic command is meant to be an arbitrary shell command, and the version variable is set at build time. - `README.md` needed no change; `make build` and `make check` do what it says they do. - There is no `LICENSE` file yet; that choice is yours. `make check` is green. Model: opus-5
clawbot added 1 commit 2026-09-07 16:45:37 +02:00
Skeleton, mnemonic input, derivation, and the ssh commands (closes #1)
All checks were successful
check / check (push) Successful in 34s
90a7c96cd1
The tool derives ed25519 SSH keys from a BIP-39 mnemonic and prints
them, either as an authorized_keys line or as an unencrypted OpenSSH
private key. Both README test vectors are asserted in the tests.

The mnemonic is looked for in the order the README gives, and refused
when it fails its checksum or when there is nowhere left to look.

The repository standards come with it: the vendored linter
configuration and policies, the script/ entrypoints with a thin
Makefile, and a Gitea workflow. Linting happens only inside the image
built from Dockerfile.lint, which pins the linter by hash, so the root
Dockerfile runs the formatting check, the tests and the build, and
script/cibuild runs the linter before it.

Model: opus-5
clawbot added the needs-review label 2026-09-07 16:45:42 +02:00
clawbot self-assigned this 2026-09-07 16:45:42 +02:00
Author
Collaborator

FAIL — needs-rework. Two findings, the first blocking.

1. internal/derive/derive.go:23, with the flag at internal/cli/options/options.go:19. --index is a uint32, so the tool accepts 0 through 4294967295, but a hardened BIP-32 child index only reaches 2147483647. For any index at or above 2147483648 the hardened offset added while parsing the path wraps around, and the derivation silently falls through to a non-hardened child instead. keyfunc ssh pub -n 2147483648 prints exactly the key that the path m/83696968'/838372'/0 (no apostrophe on the last element) produces, labelled keyfunc/ssh/2147483648, and says nothing. This matters because the README presents the path as a standard any implementation can follow: half the range the tool advertises in its own help text yields keys that no conforming implementation reproduces, with no error and no warning. Acceptable: refuse an index at or above 2147483648 with a clear message before any derivation happens, plus a test pinning the refusal.

2. script/install-precommit:10. hook=".git/hooks/pre-commit" is assigned and never read; the following two lines write and chmod the same path as a literal. A reader hunts for the use and finds none. Acceptable: use the variable below, or drop the line.

What I checked. I read every file in the diff; the code reads plainly in one pass and coins no vocabulary, and README.md is untouched. I walked the definition of done item by item against the tree. I ran make check in the fresh clone, and re-ran the linter with its Docker cache defeated so the linting step genuinely executed rather than replaying a cached layer. I built the binary and compared both README test vectors byte for byte against its output, fed the printed private key to ssh-keygen to confirm it yields the same public key and comment, and exercised each mnemonic source, the precedence between them, the bad-checksum refusal, the no-terminal refusal and the exit statuses. I planted defects — a wrong application number, a swapped source order, a dropped whitespace trim — and confirmed the tests catch each. The vendored .golangci.yml and REPO_POLICIES.md are byte-identical to the canonical copies.

Open points, for the owner rather than the author. There is no LICENSE; the issue does not ask for one and the policy says to ask you. The root Dockerfile has no lint stage, which departs from REPO_POLICIES.md; I read the issue's explicit call for Dockerfile.lint as overriding it.

Model: opus-5

**FAIL** — needs-rework. Two findings, the first blocking. **1. `internal/derive/derive.go:23`, with the flag at `internal/cli/options/options.go:19`.** `--index` is a `uint32`, so the tool accepts 0 through 4294967295, but a hardened BIP-32 child index only reaches 2147483647. For any index at or above 2147483648 the hardened offset added while parsing the path wraps around, and the derivation silently falls through to a **non-hardened** child instead. `keyfunc ssh pub -n 2147483648` prints exactly the key that the path `m/83696968'/838372'/0` (no apostrophe on the last element) produces, labelled `keyfunc/ssh/2147483648`, and says nothing. This matters because the README presents the path as a standard any implementation can follow: half the range the tool advertises in its own help text yields keys that no conforming implementation reproduces, with no error and no warning. Acceptable: refuse an index at or above 2147483648 with a clear message before any derivation happens, plus a test pinning the refusal. **2. `script/install-precommit:10`.** `hook=".git/hooks/pre-commit"` is assigned and never read; the following two lines write and chmod the same path as a literal. A reader hunts for the use and finds none. Acceptable: use the variable below, or drop the line. **What I checked.** I read every file in the diff; the code reads plainly in one pass and coins no vocabulary, and `README.md` is untouched. I walked the definition of done item by item against the tree. I ran `make check` in the fresh clone, and re-ran the linter with its Docker cache defeated so the linting step genuinely executed rather than replaying a cached layer. I built the binary and compared both README test vectors byte for byte against its output, fed the printed private key to `ssh-keygen` to confirm it yields the same public key and comment, and exercised each mnemonic source, the precedence between them, the bad-checksum refusal, the no-terminal refusal and the exit statuses. I planted defects — a wrong application number, a swapped source order, a dropped whitespace trim — and confirmed the tests catch each. The vendored `.golangci.yml` and `REPO_POLICIES.md` are byte-identical to the canonical copies. **Open points, for the owner rather than the author.** There is no `LICENSE`; the issue does not ask for one and the policy says to ask you. The root `Dockerfile` has no lint stage, which departs from `REPO_POLICIES.md`; I read the issue's explicit call for `Dockerfile.lint` as overriding it. Model: opus-5
clawbot added needs-rework and removed needs-review labels 2026-09-07 16:57:38 +02:00
clawbot added 1 commit 2026-09-07 17:08:38 +02:00
Refuse a key index with no hardened child
All checks were successful
check / check (push) Successful in 23s
731ffffb5f
Every element of the derivation path is hardened, so an index above
2147483647 has no child to derive: the hardened offset wrapped around
and the tool silently produced a non-hardened key that no other
implementation reading the path as written would reproduce. Such an
index is now refused with a message before anything is derived, and
tests pin the refusal at both the derivation and the command level.

Also use the hook path variable in script/install-precommit instead of
repeating the literal beside it.

Model: opus-5
Author
Collaborator

Both findings addressed on issue-1-skeleton.

1. Every element of the path is hardened, so an index above 2147483647 has no hardened child; the tool used to wrap around and print a key from a non-hardened path with no warning. Such an index is now refused with a message before anything is derived, and two tests pin it: one on the derivation, one on keyfunc ssh pub -n 2147483648, which now exits 1 and prints nothing on standard output.

2. The hook path variable is now used on both lines that had repeated the literal beside it.

Nothing else changed; README.md untouched. make check is green, with the linter run in Docker.

Model: opus-5

Both findings addressed on `issue-1-skeleton`. **1.** Every element of the path is hardened, so an index above 2147483647 has no hardened child; the tool used to wrap around and print a key from a non-hardened path with no warning. Such an index is now refused with a message before anything is derived, and two tests pin it: one on the derivation, one on `keyfunc ssh pub -n 2147483648`, which now exits 1 and prints nothing on standard output. **2.** The hook path variable is now used on both lines that had repeated the literal beside it. Nothing else changed; `README.md` untouched. `make check` is green, with the linter run in Docker. Model: opus-5
clawbot added needs-review and removed needs-rework labels 2026-09-07 17:08:53 +02:00
Author
Collaborator

PASS — no findings. Both round-one findings are fixed and nothing else moved.

Index refusal. Built the binary from the branch head and ran it: -n 2147483648 and -n 4294967295 write nothing to standard output, print keyfunc: the key index is too large: 2147483648 is above 2147483647 to standard error and exit 1, for both ssh pub and ssh priv; -n 2147483647 still prints a key. The check sits in internal/derive/derive.go ahead of the seed, so nothing is derived. I removed the check in a scratch copy and both new tests, in internal/derive/derive_test.go and internal/cli/cli_test.go, fail. I moved the comparison by one and the derivation test fails again, so the boundary is pinned in both directions. Out-of-range and non-numeric -n values are refused by the flag parser with exit 1.

Hook script. script/install-precommit now uses hook on both lines; run against a scratch repository it writes the hook and marks it executable.

README vectors. I extracted both authorized_keys lines from README.md and compared them to the binary's output for indices 0 and 1: identical. README.md is untouched.

Gate. make check in this fresh clone exits 0, with the linter step genuinely executing inside the image rather than replaying a cached layer. The head is a fast-forward on the current next. Both commit messages and the pull request body carry a model line and no attribution.

Scope. The rework touches four files, all inside the two findings.

Judgement call: the index is checked after the mnemonic has been fetched, so a --mnemonic-command runs before the refusal is printed. The finding asked for refusal before derivation, which this is, so I did not treat it as a defect.

Model: opus-5

**PASS** — no findings. Both round-one findings are fixed and nothing else moved. **Index refusal.** Built the binary from the branch head and ran it: `-n 2147483648` and `-n 4294967295` write nothing to standard output, print `keyfunc: the key index is too large: 2147483648 is above 2147483647` to standard error and exit 1, for both `ssh pub` and `ssh priv`; `-n 2147483647` still prints a key. The check sits in `internal/derive/derive.go` ahead of the seed, so nothing is derived. I removed the check in a scratch copy and both new tests, in `internal/derive/derive_test.go` and `internal/cli/cli_test.go`, fail. I moved the comparison by one and the derivation test fails again, so the boundary is pinned in both directions. Out-of-range and non-numeric `-n` values are refused by the flag parser with exit 1. **Hook script.** `script/install-precommit` now uses `hook` on both lines; run against a scratch repository it writes the hook and marks it executable. **README vectors.** I extracted both `authorized_keys` lines from `README.md` and compared them to the binary's output for indices 0 and 1: identical. `README.md` is untouched. **Gate.** `make check` in this fresh clone exits 0, with the linter step genuinely executing inside the image rather than replaying a cached layer. The head is a fast-forward on the current `next`. Both commit messages and the pull request body carry a model line and no attribution. **Scope.** The rework touches four files, all inside the two findings. Judgement call: the index is checked after the mnemonic has been fetched, so a `--mnemonic-command` runs before the refusal is printed. The finding asked for refusal before derivation, which this is, so I did not treat it as a defect. Model: opus-5
clawbot merged commit 279cba6bcf into next 2026-09-07 17:34:54 +02:00
clawbot deleted branch issue-1-skeleton 2026-09-07 17:34:55 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/keyfunc#5