Compare commits
1
Commits
next
..
bd00e4bc11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd00e4bc11 |
@@ -16,7 +16,6 @@ linters:
|
|||||||
- depguard # Dependency allow/block lists
|
- depguard # Dependency allow/block lists
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
settings:
|
settings:
|
||||||
|
|||||||
@@ -1,74 +1,16 @@
|
|||||||
# keyfunc
|
# keyfunc
|
||||||
|
|
||||||
`keyfunc` is a Go command-line tool by [@sneak](https://sneak.berlin) — its
|
`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from
|
||||||
license is not yet chosen
|
that mnemonic at any time. The same mnemonic, key type and index always give the
|
||||||
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — that turns a BIP-39
|
same key.
|
||||||
mnemonic into SSH keys, age identities and child mnemonics, each of which can be
|
|
||||||
recreated from that mnemonic at any time. The same mnemonic, key type and index
|
|
||||||
always give the same key.
|
|
||||||
|
|
||||||
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
It uses the BIP-85 entropy deriver from `git.eeqj.de/sneak/secret/pkg/bip85` and
|
||||||
takes the same steps as that repository's `agehd` package.
|
takes the same steps as that repository's `agehd` package.
|
||||||
|
|
||||||
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys,
|
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH
|
||||||
`keyfunc age ...` for age identities and for encrypting and decrypting with
|
keys, `keyfunc age ...` for age identities and for encrypting and decrypting
|
||||||
them, and `keyfunc mnemonic ...` for child mnemonics derived from the main one.
|
with them, and `keyfunc mnemonic ...` for child mnemonics derived from the
|
||||||
|
main one.
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
Build from a clone and run the binary:
|
|
||||||
|
|
||||||
```
|
|
||||||
git clone git@git.eeqj.de:sneak/keyfunc.git
|
|
||||||
cd keyfunc
|
|
||||||
make build
|
|
||||||
./keyfunc --version
|
|
||||||
```
|
|
||||||
|
|
||||||
`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:
|
|
||||||
|
|
||||||
```
|
|
||||||
./keyfunc ssh pub -n 0 --mnemonic-command 'secret get foo'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rationale
|
|
||||||
|
|
||||||
A key you can derive again never has to be backed up. One mnemonic, kept safe
|
|
||||||
once, stands behind every key this tool produces: lose a laptop and the SSH key,
|
|
||||||
the age identity and any child mnemonic on it come back from the mnemonic alone,
|
|
||||||
at the same index, byte for byte. Nothing else has to be written down, copied
|
|
||||||
between machines, or stored in a secret manager, because it can always be
|
|
||||||
derived again.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The entry point is a thin `cmd/keyfunc/main.go` (what `make build` builds) that
|
|
||||||
calls into `internal/`. The packages there are:
|
|
||||||
|
|
||||||
- `internal/derive` turns a mnemonic into the 32 bytes a key is made from: it
|
|
||||||
walks BIP-39 seed, BIP-32 master key and BIP-85 entropy, and holds the shared
|
|
||||||
constants (the byte count and the largest key index).
|
|
||||||
- `internal/mnemonic` finds the mnemonic to work from — a command, an
|
|
||||||
environment variable, or a terminal prompt — and refuses one that fails the
|
|
||||||
BIP-39 checksum.
|
|
||||||
- `internal/sshkey` turns the derived bytes into an ed25519 SSH key
|
|
||||||
(`sshkey.go`) and serves that key from an in-process SSH agent on a private
|
|
||||||
unix socket, keeping it out of any file (`agent.go`).
|
|
||||||
- `internal/agekey` turns the derived bytes into an age identity and encrypts
|
|
||||||
and decrypts with it.
|
|
||||||
- `internal/childmnemonic` derives a child mnemonic from the main one using
|
|
||||||
BIP-85's own mnemonic application.
|
|
||||||
- `internal/cli` builds the cobra command tree and runs it. Under it,
|
|
||||||
`cli/options` holds the flags every command shares, and `cli/ssh`, `cli/age`
|
|
||||||
and `cli/mnemonic` are the command groups.
|
|
||||||
|
|
||||||
### Adding a key type
|
|
||||||
|
|
||||||
Adding a key type is one package under `internal/` that turns the 32 derived
|
|
||||||
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
|
|
||||||
groups its commands.
|
|
||||||
|
|
||||||
## Derivation
|
## Derivation
|
||||||
|
|
||||||
@@ -216,15 +158,6 @@ 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
|
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.
|
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`:
|
|
||||||
|
|
||||||
```
|
|
||||||
recipient index 0: age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysewlj68kmx5n67e3nsg2mftq
|
|
||||||
recipient index 1: age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfgqm7hmakqhvgyy4q5p3w7j
|
|
||||||
identity index 0: AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD7527T70JDHGDJ7AMNF3XT44S00JFU5
|
|
||||||
```
|
|
||||||
|
|
||||||
### `keyfunc age pub`
|
### `keyfunc age pub`
|
||||||
|
|
||||||
Prints the recipient, the `age1...` public key, on one line.
|
Prints the recipient, the `age1...` public key, on one line.
|
||||||
@@ -257,64 +190,33 @@ 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
|
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.
|
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
|
## Adding a key type
|
||||||
vectors, which derive from the specification's master key
|
|
||||||
`xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb`.
|
|
||||||
At key index 0 the 12-word English child mnemonic is:
|
|
||||||
|
|
||||||
```
|
Adding a key type is one package under `internal/` that turns the 32 derived
|
||||||
girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
|
||||||
```
|
groups its commands.
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
Errors go to standard error and the exit status is 1, except for `ssh to`,
|
Errors go to standard error and the exit status is 1, except for `ssh to`,
|
||||||
which passes through `ssh`'s own exit status.
|
which passes through `ssh`'s own exit status.
|
||||||
|
|
||||||
## Entrypoints
|
## Building and running
|
||||||
|
|
||||||
The repo adheres to the
|
```
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
make build # produces ./keyfunc
|
||||||
standard: most Makefile targets are thin shims over an executable in
|
make check # fmt-check, lint (golangci-lint) and tests
|
||||||
`script/` (`build` and `clean` are the exceptions).
|
```
|
||||||
|
|
||||||
- `script/bootstrap` installs everything needed to build and develop (git, make,
|
Examples:
|
||||||
Go), idempotently, from nix, apt, brew or apk; 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
|
|
||||||
stay identical across repos.
|
|
||||||
- `script/test` runs `go vet` and then the test suite, rerunning verbosely if a
|
|
||||||
test fails.
|
|
||||||
- `script/lint` runs the linter inside the image built from `Dockerfile.lint`
|
|
||||||
(which pins the linter by hash), so a complaint fails the build 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/check` runs `test`, `lint` and `fmt-check` and changes no files.
|
|
||||||
- `script/docker` builds the Docker image tagged with the project name.
|
|
||||||
- `script/cibuild` is the CI build the Gitea workflow calls: it runs the linter,
|
|
||||||
then `docker build`.
|
|
||||||
- `script/precommit` is what the git pre-commit hook runs: `go mod tidy` and
|
|
||||||
`go fmt`, failing if `go.mod` or `go.sum` changed, then `check`.
|
|
||||||
- `script/install-precommit` installs the git pre-commit hook that runs
|
|
||||||
`script/precommit`.
|
|
||||||
|
|
||||||
## TODO
|
```
|
||||||
|
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
|
||||||
The open issues that stand between the tree and a 1.0 release:
|
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
|
||||||
|
keyfunc ssh install -n 3 user@example.com
|
||||||
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
|
keyfunc ssh to -n 3 user@example.com uptime
|
||||||
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
|
keyfunc age pub -n 0
|
||||||
|
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
|
||||||
## License
|
keyfunc age decrypt -n 0 notes.age
|
||||||
|
keyfunc mnemonic -n 1 --words 24
|
||||||
Not yet chosen. The license is the owner's decision, still open on the tracker
|
```
|
||||||
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); the `LICENSE` file is added
|
|
||||||
when that issue is answered.
|
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
[@sneak](https://sneak.berlin).
|
|
||||||
|
|||||||
+19
-69
@@ -2,13 +2,12 @@ package cli_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,23 +17,6 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runAsTool, set in the environment of a re-executed test binary, tells
|
|
||||||
// TestMain to run the tool through Main rather than the suite, so the
|
|
||||||
// signal test can drive the real signal path in a process it can send a
|
|
||||||
// signal to.
|
|
||||||
const runAsTool = "KEYFUNC_TEST_RUN_AS_TOOL"
|
|
||||||
|
|
||||||
// TestMain re-executes the test binary as the tool when runAsTool is
|
|
||||||
// set, and otherwise runs the suite. The signal test starts the tool
|
|
||||||
// this way, as a subprocess it can signal and watch clean up.
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
if os.Getenv(runAsTool) == "1" {
|
|
||||||
os.Exit(cli.Main())
|
|
||||||
}
|
|
||||||
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
// The modes the host is supposed to end up with, and the mode the
|
// The modes the host is supposed to end up with, and the mode the
|
||||||
// stand-ins need so that they can be run at all.
|
// stand-ins need so that they can be run at all.
|
||||||
const (
|
const (
|
||||||
@@ -491,68 +473,36 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) {
|
|||||||
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
||||||
t.Setenv(mnemonic.Variable, example())
|
t.Setenv(mnemonic.Variable, example())
|
||||||
|
|
||||||
// The three signals the tool handles, checked one after another.
|
|
||||||
signals := []struct {
|
|
||||||
name string
|
|
||||||
signal os.Signal
|
|
||||||
}{
|
|
||||||
{"SIGTERM", syscall.SIGTERM},
|
|
||||||
{"SIGINT", syscall.SIGINT},
|
|
||||||
{"SIGHUP", syscall.SIGHUP},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ending := range signals {
|
|
||||||
signalEndsTheTool(t, ending.name, ending.signal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// signalEndsTheTool runs the tool as a subprocess against a stand-in
|
|
||||||
// ssh that blocks, waits until the agent is up and ssh is running
|
|
||||||
// against it, sends the tool the signal, and requires the agent socket
|
|
||||||
// and its directory to be gone once the tool has ended. The subprocess
|
|
||||||
// goes through Main and its signal handling, so with that handling
|
|
||||||
// removed the signal kills the tool outright, no deferred cleanup runs,
|
|
||||||
// the directory is left behind, and the check fails.
|
|
||||||
func signalEndsTheTool(t *testing.T, name string, signal os.Signal) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
noted := filepath.Join(t.TempDir(), "socket")
|
noted := filepath.Join(t.TempDir(), "socket")
|
||||||
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
t.Setenv("KEYFUNC_TEST_SOCKET", noted)
|
||||||
standIn(t, "ssh", sleeper)
|
standIn(t, "ssh", sleeper)
|
||||||
|
|
||||||
//nolint:gosec // the binary is this test's own, re-run as the tool
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
command := exec.CommandContext(
|
t.Cleanup(cancel)
|
||||||
t.Context(), os.Args[0], subcommand, "to", host, remoteCommand,
|
|
||||||
)
|
|
||||||
|
|
||||||
command.Env = append(os.Environ(), runAsTool+"=1")
|
root := cli.Root()
|
||||||
require.NoError(t, command.Start())
|
root.SetOut(&bytes.Buffer{})
|
||||||
|
root.SetErr(&bytes.Buffer{})
|
||||||
// The stand-in notes the socket only once the agent is up and ssh
|
root.SetArgs([]string{subcommand, "to", host, remoteCommand})
|
||||||
// is running against it, so this is where the signal lands.
|
|
||||||
socket := waitForSocket(t, noted)
|
|
||||||
|
|
||||||
require.NoError(t, command.Process.Signal(signal))
|
|
||||||
waitForTool(t, name, command)
|
|
||||||
|
|
||||||
// The signal ended the tool, and its deferred cleanup still ran:
|
|
||||||
// the agent socket and its directory are gone.
|
|
||||||
require.NoDirExists(t, filepath.Dir(socket), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForTool waits for the subprocess to end, and fails the test if it
|
|
||||||
// does not end in time.
|
|
||||||
func waitForTool(t *testing.T, name string, command *exec.Cmd) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- command.Wait() }()
|
go func() { done <- root.ExecuteContext(ctx) }()
|
||||||
|
|
||||||
|
// The stand-in notes the socket only once it is up and ssh is
|
||||||
|
// running against it, so this is where a signal would land.
|
||||||
|
socket := waitForSocket(t, noted)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case <-time.After(10 * time.Second):
|
case <-time.After(10 * time.Second):
|
||||||
t.Fatalf("the tool did not end after %s", name)
|
t.Fatal("the tool did not end after the context was cancelled")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The deferred cleanup ran even though a cancellation, not a clean
|
||||||
|
// exit, ended ssh: the agent socket and its directory are gone.
|
||||||
|
require.NoDirExists(t, filepath.Dir(socket))
|
||||||
}
|
}
|
||||||
|
|
||||||
// waitForSocket waits for the stand-in to write down the agent socket
|
// waitForSocket waits for the stand-in to write down the agent socket
|
||||||
|
|||||||
Reference in New Issue
Block a user