Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0dcb8a03 | ||
|
|
e6ddf49acc |
@@ -1,16 +1,73 @@
|
||||
# keyfunc
|
||||
|
||||
`keyfunc` turns a BIP-39 mnemonic into key pairs that can be recreated from
|
||||
that mnemonic at any time. The same mnemonic, key type and index always give the
|
||||
same key.
|
||||
`keyfunc` is an MIT-licensed Go command-line tool by
|
||||
[@sneak](https://sneak.berlin) that turns a BIP-39 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
|
||||
takes the same steps as that repository's `agehd` package.
|
||||
|
||||
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH
|
||||
keys, `keyfunc age ...` for age identities and for encrypting and decrypting
|
||||
with them, and `keyfunc mnemonic ...` for child mnemonics derived from the
|
||||
main one.
|
||||
Commands are grouped by what is derived: `keyfunc ssh ...` for ed25519 SSH keys,
|
||||
`keyfunc age ...` for age identities and for encrypting and decrypting 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
|
||||
|
||||
@@ -55,7 +112,8 @@ refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is
|
||||
refused with a message saying so.
|
||||
|
||||
Every command takes `--index` / `-n` and `--mnemonic-command`, and has `--help`.
|
||||
`keyfunc --version` prints the version set at build time.
|
||||
`keyfunc --version` prints the version. `make build` stamps it; a binary
|
||||
installed with `go install` reports the module version instead.
|
||||
|
||||
## SSH keys: `keyfunc ssh`
|
||||
|
||||
@@ -94,24 +152,22 @@ 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.
|
||||
|
||||
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 fetches `~/.ssh/authorized_keys`. The file reads as empty
|
||||
only when `sftp` reported that file as not being there — the one line naming
|
||||
that path. The same wording anywhere else in the session does not count: `ssh`
|
||||
writes `No such file or directory` about an `-i` it cannot find, on a session
|
||||
that then authenticates through the agent. When `sftp` failed for any other
|
||||
reason — the file is there and cannot be read, the connection did not come up —
|
||||
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. What `sftp`
|
||||
cannot tell apart is a missing file and one in a directory it cannot enter, so a
|
||||
`~/.ssh` whose mode shuts the user out reads as a host with no file; the second
|
||||
connection sets that mode to `0700` and writes, as on a host that has none. 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;
|
||||
- creates `~/.ssh` and sets it to mode `0700`;
|
||||
- uploads the new file as `~/.ssh/authorized_keys.keyfunc-<random>` and sets it
|
||||
to mode `0600`;
|
||||
- renames that file over `~/.ssh/authorized_keys`.
|
||||
@@ -150,6 +206,15 @@ 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`:
|
||||
|
||||
```
|
||||
recipient index 0: age1xwdy9y6ckyfsgjc8k02e9uhsf3fmjy0ufysewlj68kmx5n67e3nsg2mftq
|
||||
recipient index 1: age1pmm92sxaf5mazjwvjph7dx2zq9r5p8l3rarfgqm7hmakqhvgyy4q5p3w7j
|
||||
identity index 0: AGE-SECRET-KEY-19QKK2P38598XLXMQFFU3P7J9PLDD7527T70JDHGDJ7AMNF3XT44S00JFU5
|
||||
```
|
||||
|
||||
### `keyfunc age pub`
|
||||
|
||||
Prints the recipient, the `age1...` public key, on one line.
|
||||
@@ -182,33 +247,69 @@ 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.
|
||||
|
||||
## Adding a key type
|
||||
Test vector: the child-mnemonic step is checked against BIP-85's own published
|
||||
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
|
||||
bytes into that type's key, plus one cobra subcommand under `internal/cli/` that
|
||||
groups its commands.
|
||||
```
|
||||
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.
|
||||
|
||||
## Building and running
|
||||
## Entrypoints
|
||||
|
||||
```
|
||||
make build # produces ./keyfunc
|
||||
make check # fmt-check, lint (golangci-lint) and tests
|
||||
```
|
||||
The repo adheres to the
|
||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||
standard: each Makefile target is a thin shim over an executable in `script/`.
|
||||
|
||||
Examples:
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
```
|
||||
keyfunc ssh pub -n 3 --mnemonic-command 'secret get foo'
|
||||
keyfunc ssh priv -n 3 > ~/.ssh/id_bip85_3
|
||||
keyfunc ssh install -n 3 user@example.com
|
||||
keyfunc ssh to -n 3 user@example.com uptime
|
||||
keyfunc age pub -n 0
|
||||
keyfunc age encrypt -n 0 --armor -o notes.age notes.txt
|
||||
keyfunc age decrypt -n 0 notes.age
|
||||
keyfunc mnemonic -n 1 --words 24
|
||||
```
|
||||
## TODO
|
||||
|
||||
The open issues that stand between the tree and a 1.0 release:
|
||||
|
||||
- [#10 ssh install needs rework](https://git.eeqj.de/sneak/keyfunc/issues/10)
|
||||
- [#13 Review toward 1.0: identify and file all next steps](https://git.eeqj.de/sneak/keyfunc/issues/13)
|
||||
- [#14 Choose a license and add LICENSE](https://git.eeqj.de/sneak/keyfunc/issues/14)
|
||||
- [#15 Decide the Go module path before 1.0](https://git.eeqj.de/sneak/keyfunc/issues/15)
|
||||
- [#16 Do not pass the mnemonic environment variables on to ssh and sftp](https://git.eeqj.de/sneak/keyfunc/issues/16)
|
||||
- [#17 Clean up the agent socket and working files when a signal ends the tool](https://git.eeqj.de/sneak/keyfunc/issues/17)
|
||||
- [#22 1.0 release readiness](https://git.eeqj.de/sneak/keyfunc/issues/22)
|
||||
|
||||
## License
|
||||
|
||||
MIT. The license is not yet settled on the tracker
|
||||
([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)); MIT is the recommended
|
||||
option there, so this README names it and the `LICENSE` file is added when that
|
||||
issue is answered.
|
||||
|
||||
## Author
|
||||
|
||||
[@sneak](https://sneak.berlin).
|
||||
|
||||
+27
-3
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
|
||||
"git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic"
|
||||
@@ -13,20 +14,43 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Version is what --version prints. The build sets it.
|
||||
// devVersion is what Version holds until a build stamps a real one.
|
||||
const devVersion = "dev"
|
||||
|
||||
// Version is what --version prints. make build stamps it with -ldflags.
|
||||
//
|
||||
//nolint:gochecknoglobals // set at build time with -ldflags
|
||||
var Version = "dev"
|
||||
var Version = devVersion
|
||||
|
||||
// resolveVersion chooses what --version reports. A value stamped at
|
||||
// build time wins. Otherwise, for a binary from go install, the module
|
||||
// version recorded in the build info is used, unless that is empty or
|
||||
// the "(devel)" of a local build. When neither names a version, the
|
||||
// "dev" fallback stays.
|
||||
func resolveVersion(stamped string, info *debug.BuildInfo) string {
|
||||
if stamped != devVersion {
|
||||
return stamped
|
||||
}
|
||||
|
||||
if info != nil && info.Main.Version != "" &&
|
||||
info.Main.Version != "(devel)" {
|
||||
return info.Main.Version
|
||||
}
|
||||
|
||||
return devVersion
|
||||
}
|
||||
|
||||
// Root returns the whole command tree.
|
||||
func Root() *cobra.Command {
|
||||
info, _ := debug.ReadBuildInfo()
|
||||
|
||||
root := &cobra.Command{
|
||||
Use: "keyfunc",
|
||||
Short: "derive key pairs from a BIP-39 mnemonic",
|
||||
Long: "keyfunc turns a BIP-39 mnemonic into key pairs that can " +
|
||||
"be recreated from that mnemonic at any time. The same " +
|
||||
"mnemonic, key type and index always give the same key.",
|
||||
Version: Version,
|
||||
Version: resolveVersion(Version, info),
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
+21
-84
@@ -76,7 +76,7 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
|
||||
|
||||
defer func() { _ = os.RemoveAll(work) }()
|
||||
|
||||
content, present, err := fetch(cmd, host, options,
|
||||
content, err := fetch(cmd, host, options,
|
||||
filepath.Join(work, "authorized_keys"),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -88,18 +88,16 @@ func add(cmd *cobra.Command, host string, options []string, line string) error {
|
||||
return write(cmd, "already present\n")
|
||||
}
|
||||
|
||||
return upload(cmd, host, options, work, merged, present)
|
||||
return upload(cmd, host, options, work, merged)
|
||||
}
|
||||
|
||||
// upload writes the new file to the host and renames it over
|
||||
// authorized_keys, which is the step that either happens or does not.
|
||||
// Nothing is removed when a step fails: the file left behind is named
|
||||
// so that it can be looked at and cleared away by hand. The directory
|
||||
// is made and set to its mode only when the read found none: an .ssh
|
||||
// that was already there is left with the mode it had.
|
||||
// so that it can be looked at and cleared away by hand.
|
||||
func upload(
|
||||
cmd *cobra.Command, host string, options []string,
|
||||
work, merged string, present bool,
|
||||
work, merged string,
|
||||
) error {
|
||||
local := filepath.Join(work, "authorized_keys.merged")
|
||||
|
||||
@@ -113,24 +111,14 @@ func upload(
|
||||
return err
|
||||
}
|
||||
|
||||
var batch []string
|
||||
|
||||
if !present {
|
||||
// The mkdir is allowed to fail in case the directory appeared
|
||||
// between the read and now; the chmod then sets its mode.
|
||||
batch = append(batch,
|
||||
"-mkdir "+directory,
|
||||
"chmod "+directoryMode+" "+directory,
|
||||
)
|
||||
}
|
||||
|
||||
batch = append(batch,
|
||||
"put "+quoted(local)+" "+sidecar,
|
||||
"chmod "+fileMode+" "+sidecar,
|
||||
"rename "+sidecar+" "+authorized,
|
||||
)
|
||||
|
||||
said, err := session(cmd, host, options, batch)
|
||||
// The mkdir may fail: the directory is usually there already.
|
||||
said, err := session(cmd, host, options, []string{
|
||||
"-mkdir " + directory,
|
||||
"chmod " + directoryMode + " " + directory,
|
||||
"put " + quoted(local) + " " + sidecar,
|
||||
"chmod " + fileMode + " " + sidecar,
|
||||
"rename " + sidecar + " " + authorized,
|
||||
})
|
||||
if err != nil {
|
||||
// sftp echoes each command as it runs it and stops at the
|
||||
// first that fails, so the name is in what it said only once
|
||||
@@ -197,82 +185,31 @@ func merge(content, line string) (string, bool) {
|
||||
}
|
||||
|
||||
// fetch brings the host's authorized_keys into the given path and
|
||||
// returns what is in it, and whether the .ssh directory was already
|
||||
// there. The one session lists .ssh and then gets the file, so the
|
||||
// listing settles the state of the directory before the get is read.
|
||||
//
|
||||
// The file reads as empty in just two cases: sftp reported .ssh itself
|
||||
// as not there, or the listing succeeded and the get then reported the
|
||||
// file as not there. Anything else — the listing refused, the file
|
||||
// there but unreadable, the connection down — fails the run and writes
|
||||
// nothing, because writing back over what was not read would leave the
|
||||
// host with the new key and nothing else. sftp cannot tell a missing
|
||||
// file from one in a directory it cannot enter, so the listing does:
|
||||
// a directory that is there but cannot be read is a failure, not an
|
||||
// empty file.
|
||||
// returns what is in it. A host that has no such file reads as empty,
|
||||
// but only when that is what sftp said about it: a file that is there
|
||||
// and cannot be read fails the run, because writing back over it
|
||||
// would leave the host with the new key and nothing else.
|
||||
func fetch(
|
||||
cmd *cobra.Command, host string, options []string, into string,
|
||||
) (string, bool, error) {
|
||||
) (string, error) {
|
||||
said, err := session(cmd, host, options, []string{
|
||||
"ls -1 " + directory,
|
||||
"get " + authorized + " " + quoted(into),
|
||||
})
|
||||
if err != nil {
|
||||
if directoryAbsent(said) {
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
if absent(said) {
|
||||
return "", true, nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", false, err
|
||||
return "", err
|
||||
}
|
||||
|
||||
//nolint:gosec // the path is a temporary file of the tool's own
|
||||
content, err := os.ReadFile(into)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("reading the fetched file: %w", err)
|
||||
return "", fmt.Errorf("reading the fetched file: %w", err)
|
||||
}
|
||||
|
||||
return string(content), true, nil
|
||||
}
|
||||
|
||||
// directoryAbsent says whether sftp reported .ssh itself as not being
|
||||
// there, which is the one listing failure read as a host that has no
|
||||
// authorized_keys yet. The reading is taken only from the line in which
|
||||
// sftp reports on that directory: any other failure of the listing, in
|
||||
// particular a directory that is there but cannot be entered, is left
|
||||
// as a failure, so that no key is written to a host whose keys were
|
||||
// never read.
|
||||
func directoryAbsent(said string) bool {
|
||||
for line := range strings.Lines(said) {
|
||||
named, is := reportedCannotList(strings.TrimSpace(line))
|
||||
if is && (named == directory ||
|
||||
strings.HasSuffix(named, "/"+directory)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// reportedCannotList returns the path an sftp line reports it cannot
|
||||
// list for want of the directory, and whether the line is such a
|
||||
// report. The client writes this one wording when the directory a
|
||||
// listing names is not there, giving the path the server expanded.
|
||||
func reportedCannotList(line string) (string, bool) {
|
||||
const (
|
||||
before = `Can't ls: "`
|
||||
after = `" not found`
|
||||
)
|
||||
|
||||
if !strings.HasPrefix(line, before) ||
|
||||
!strings.HasSuffix(line, after) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// absent says whether sftp reported the file that was asked for as
|
||||
|
||||
@@ -10,7 +10,6 @@ import "testing"
|
||||
const (
|
||||
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
|
||||
`
|
||||
listed = "sftp> ls -1 .ssh\n"
|
||||
warning = `Warning: Identity file /gone not accessible: ` +
|
||||
"No such file or directory.\n"
|
||||
)
|
||||
@@ -77,57 +76,3 @@ func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo holds the
|
||||
// wordings the OpenSSH client was seen to use when a listing fails: a
|
||||
// directory it cannot find is reported one way, and one it cannot enter
|
||||
// another, and only the first is read as a host with no .ssh yet.
|
||||
func TestTheDirectoryIsReadAsAbsentOnlyFromTheListingSayingSo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listings := map[string]struct {
|
||||
said string
|
||||
want bool
|
||||
}{
|
||||
"the directory is not there": {
|
||||
said: listed + `Can't ls: "/home/someone/.ssh" not found` + "\n",
|
||||
want: true,
|
||||
},
|
||||
"the directory is not there, named as it was asked for": {
|
||||
said: listed + `Can't ls: ".ssh" not found` + "\n",
|
||||
want: true,
|
||||
},
|
||||
"the directory is not there and an identity file is not either": {
|
||||
said: warning + listed +
|
||||
`Can't ls: "/home/someone/.ssh" not found` + "\n",
|
||||
want: true,
|
||||
},
|
||||
"the directory is there and cannot be entered": {
|
||||
said: listed +
|
||||
`remote readdir("/home/someone/.ssh/"): Permission denied` + "\n",
|
||||
want: false,
|
||||
},
|
||||
"some other directory is not there": {
|
||||
said: listed + `Can't ls: "/home/someone/.config" not found` + "\n",
|
||||
want: false,
|
||||
},
|
||||
"the connection did not come up": {
|
||||
said: "ssh: connect to host example.com port 22: " +
|
||||
"Connection refused\nConnection closed\n",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for name, listing := range listings {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if directoryAbsent(listing.said) != listing.want {
|
||||
t.Errorf(
|
||||
"read as absent: %t, wanted %t, from:\n%s",
|
||||
!listing.want, listing.want, listing.said,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+21
-122
@@ -68,18 +68,13 @@ const keyLine = vectorZero + " keyfunc/ssh/0\n"
|
||||
// work. A command that begins with a dash may fail; any other failure
|
||||
// ends the session, as it does in sftp's own batch mode.
|
||||
//
|
||||
// The listing and the two ways a get can fail are worded as the
|
||||
// OpenSSH client words them, each naming the path the server expanded.
|
||||
// A listing fails one way when .ssh is not there and another when it is
|
||||
// there but shut to the user; the first is the only failure read as a
|
||||
// host with no file. A get fails one way for a file that is not there,
|
||||
// which after a listing that came up empty is also read as no file, and
|
||||
// another for a file that is there and cannot be read, which is a
|
||||
// failure. A directory shut to the user is stood in for by mode 000,
|
||||
// which the listing reads off the mode itself so that the test does not
|
||||
// turn on the user it runs as. An -i naming a file that is not here
|
||||
// draws the warning ssh writes for it, which carries the wording of a
|
||||
// missing file into a session that goes on to authenticate.
|
||||
// The two ways a get can fail are worded as the OpenSSH client words
|
||||
// them, both naming the path the server expanded: a file that is not
|
||||
// there, which is the one failure the tool reads as an empty file, and
|
||||
// a file that is there and cannot be read, which is not. An -i naming
|
||||
// a file that is not here draws the warning ssh writes for it, which
|
||||
// carries the wording of a missing file into a session that goes on to
|
||||
// authenticate.
|
||||
const installer = `
|
||||
previous=
|
||||
for argument in "$@"; do
|
||||
@@ -104,23 +99,6 @@ while IFS= read -r line; do
|
||||
eval "set -- $line"
|
||||
worked=yes
|
||||
case "$1" in
|
||||
ls)
|
||||
dir=$2
|
||||
[ "$dir" = -1 ] && dir=$3
|
||||
if [ ! -e "$home/$dir" ]; then
|
||||
worked=no
|
||||
printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2
|
||||
elif [ -d "$home/$dir" ] && [ "$(stat -c '%a' "$home/$dir")" = 0 ]; then
|
||||
worked=no
|
||||
printf 'remote readdir("%s/"): Permission denied\n' \
|
||||
"$home/$dir" >&2
|
||||
else
|
||||
for entry in "$home/$dir"/*; do
|
||||
[ -e "$entry" ] || continue
|
||||
printf '%s/%s\n' "$dir" "$(basename "$entry")"
|
||||
done
|
||||
fi
|
||||
;;
|
||||
get)
|
||||
if [ ! -e "$home/$2" ]; then
|
||||
worked=no
|
||||
@@ -198,8 +176,8 @@ func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) {
|
||||
require.Equal(t, "already present\n", install(t, host))
|
||||
require.Equal(t, "somebody else\n"+keyLine, read(t, path))
|
||||
|
||||
// The read and nothing after it: the tool did not connect again.
|
||||
require.Equal(t, 1, connections(t, pretend))
|
||||
// The fetch and nothing after it: the tool did not connect again.
|
||||
require.Len(t, recorded(t, pretend.batch), 1)
|
||||
}
|
||||
|
||||
func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) {
|
||||
@@ -240,9 +218,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
||||
strings.HasPrefix(beside, ".ssh/authorized_keys.keyfunc-"),
|
||||
)
|
||||
|
||||
// The listing fails on a host with no .ssh, so the get never runs;
|
||||
// the write session then makes the directory and puts the file.
|
||||
require.Equal(t, "ls -1 .ssh", sent[0])
|
||||
require.True(t, strings.HasPrefix(sent[0], "get .ssh/authorized_keys "))
|
||||
require.Equal(t, "-mkdir .ssh", sent[1])
|
||||
require.Equal(t, "chmod 700 .ssh", sent[2])
|
||||
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||
@@ -261,57 +237,12 @@ func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) {
|
||||
require.Empty(t, printed)
|
||||
require.Contains(t, said, "Permission denied")
|
||||
|
||||
// The read and nothing after it, and what was on the host is
|
||||
// The fetch and nothing after it, and what was on the host is
|
||||
// still what is on the host.
|
||||
require.Equal(t, 1, connections(t, pretend))
|
||||
require.Len(t, recorded(t, pretend.batch), 1)
|
||||
require.DirExists(t, unreadable)
|
||||
}
|
||||
|
||||
func TestAnUnreadableDirectoryIsNotWrittenInto(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
pretend := pretendHost(t)
|
||||
unlistable(t, pretend)
|
||||
|
||||
// The listing is refused, which is not the same as no directory, so
|
||||
// the tool writes nothing rather than treat a directory it cannot
|
||||
// enter as a host with no file.
|
||||
printed, said, err := attempt(t, host)
|
||||
require.Error(t, err)
|
||||
require.Empty(t, printed)
|
||||
require.Contains(t, said, "Permission denied")
|
||||
|
||||
// The read and nothing after it: no second connection wrote a key.
|
||||
require.Equal(t, 1, connections(t, pretend))
|
||||
}
|
||||
|
||||
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
pretend := pretendHost(t)
|
||||
|
||||
// A directory that is there but holds no file yet, made with a mode
|
||||
// of its own so that a stray chmod would show.
|
||||
const ownMode = 0o755
|
||||
|
||||
directory := filepath.Join(pretend.home, keptUnder)
|
||||
require.NoError(t, os.Mkdir(directory, ownMode))
|
||||
|
||||
require.Equal(t, "added\n", install(t, host))
|
||||
|
||||
// The key is added and the directory keeps the mode it had: the
|
||||
// write session neither made it nor set its mode.
|
||||
require.Equal(t, keyLine, read(t, filepath.Join(directory, keptIn)))
|
||||
|
||||
kept, err := os.Stat(directory)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(ownMode), kept.Mode().Perm())
|
||||
|
||||
sent := recorded(t, pretend.batch)
|
||||
require.NotContains(t, sent, "-mkdir .ssh")
|
||||
require.NotContains(t, sent, "chmod 700 .ssh")
|
||||
}
|
||||
|
||||
func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
@@ -328,7 +259,7 @@ func TestAWarningAboutAnotherFileIsNotTakenForTheOneAskedFor(t *testing.T) {
|
||||
require.Contains(t, said, "No such file or directory")
|
||||
require.Contains(t, said, "Permission denied")
|
||||
|
||||
require.Equal(t, 1, connections(t, pretend))
|
||||
require.Len(t, recorded(t, pretend.batch), 1)
|
||||
require.DirExists(t, unreadable)
|
||||
|
||||
// The same run again, this way for the status it ends with.
|
||||
@@ -348,9 +279,9 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
||||
|
||||
pretend := pretendHost(t)
|
||||
|
||||
// A file where the .ssh directory belongs: the listing shows it and
|
||||
// so the directory reads as already there, but then the put has
|
||||
// nowhere to put anything, so the write session ends at the put.
|
||||
// A file where the .ssh directory belongs: nothing is there to
|
||||
// fetch, and then the put has nowhere to put anything, so the
|
||||
// write session ends at the put.
|
||||
inTheWay := filepath.Join(pretend.home, keptUnder)
|
||||
require.NoError(t,
|
||||
os.WriteFile(inTheWay, []byte(notADirectory), fileMode),
|
||||
@@ -361,12 +292,12 @@ func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) {
|
||||
require.Empty(t, printed)
|
||||
require.Contains(t, said, "put failed")
|
||||
|
||||
// The put is the first and last command the write session got to,
|
||||
// and the file it was uploading is the one the message names.
|
||||
// The put is the last command the session got to, and the file it
|
||||
// was uploading is the one the message names.
|
||||
sent := recorded(t, pretend.batch)
|
||||
require.Len(t, sent, 3)
|
||||
require.Equal(t, "put", strings.Fields(sent[2])[0])
|
||||
require.Contains(t, err.Error(), strings.Fields(sent[2])[2])
|
||||
require.Len(t, sent, 4)
|
||||
require.Equal(t, "put", strings.Fields(sent[3])[0])
|
||||
require.Contains(t, err.Error(), strings.Fields(sent[3])[2])
|
||||
|
||||
require.Equal(t, notADirectory, read(t, inTheWay))
|
||||
|
||||
@@ -524,38 +455,6 @@ func unfetchable(t *testing.T, pretend pretended) string {
|
||||
return path
|
||||
}
|
||||
|
||||
// unlistable puts a .ssh on the stand-in host that is there but shut to
|
||||
// the user, a directory of mode 000, and gives back its path. Its mode
|
||||
// is put back before the temporary directory is cleared so that it can
|
||||
// be.
|
||||
func unlistable(t *testing.T, pretend pretended) string {
|
||||
t.Helper()
|
||||
|
||||
directory := filepath.Join(pretend.home, keptUnder)
|
||||
require.NoError(t, os.Mkdir(directory, directoryMode))
|
||||
require.NoError(t, os.Chmod(directory, 0))
|
||||
|
||||
t.Cleanup(func() { _ = os.Chmod(directory, directoryMode) })
|
||||
|
||||
return directory
|
||||
}
|
||||
|
||||
// connections returns how many times the tool ran sftp, counted from
|
||||
// the -b that opens each session's arguments.
|
||||
func connections(t *testing.T, pretend pretended) int {
|
||||
t.Helper()
|
||||
|
||||
count := 0
|
||||
|
||||
for _, argument := range recorded(t, pretend.arguments) {
|
||||
if argument == "-b" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// pretendCall puts the to stand-in on the path and gives back the file
|
||||
// the arguments are written down in and the file the agent socket is
|
||||
// noted in.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"runtime/debug"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := &debug.BuildInfo{Main: debug.Module{Version: "v1.2.3"}}
|
||||
local := &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}
|
||||
empty := &debug.BuildInfo{}
|
||||
|
||||
t.Run("stamped value wins over build info", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, "v0.1.0", resolveVersion("v0.1.0", release))
|
||||
})
|
||||
|
||||
t.Run("go install reports the module version", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, "v1.2.3", resolveVersion(devVersion, release))
|
||||
})
|
||||
|
||||
t.Run("a local build stays dev", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, devVersion, resolveVersion(devVersion, local))
|
||||
})
|
||||
|
||||
t.Run("no version anywhere stays dev", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, devVersion, resolveVersion(devVersion, empty))
|
||||
require.Equal(t, devVersion, resolveVersion(devVersion, nil))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user