diff --git a/.golangci.yml b/.golangci.yml index 26b1610..8a90995 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -16,6 +16,7 @@ linters: - depguard # Dependency allow/block lists - godot # Requires comments to end with periods - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 - wrapcheck # Too verbose for internal packages - varnamelen # Short names like db, id are idiomatic Go settings: diff --git a/Makefile b/Makefile index af82436..b977ebe 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ setup: @script/setup build: - go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc . + go build -trimpath -ldflags "$(LDFLAGS)" -o keyfunc ./cmd/keyfunc test: @script/test diff --git a/README.md b/README.md index a5b03d6..2c83234 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,74 @@ # 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 a Go command-line tool by [@sneak](https://sneak.berlin) — its +license is not yet chosen +([#14](https://git.eeqj.de/sneak/keyfunc/issues/14)) — 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 @@ -54,8 +112,14 @@ If none of these is available and standard input is not a terminal, the tool refuses and exits with status 1. A mnemonic that fails the BIP-39 checksum is 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. + 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` @@ -88,17 +152,51 @@ Prints the unencrypted private key in OpenSSH format (the and nothing else, so it can be redirected into a file. The key's comment is the same as for `pub`. -### `keyfunc ssh install <[user@]host> [-- ssh options...]` +### `keyfunc ssh install <[user@]host> [-- sftp options...]` -Runs the system `ssh` to the host and, on the host: +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. -- creates `~/.ssh` with mode `0700` if it is missing; -- creates `~/.ssh/authorized_keys` with mode `0600` if it is missing; -- appends the `pub` line only if an identical line is not already there. +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: -It then prints `added` or `already present`. How this `ssh` connection -authenticates is up to the user's normal `ssh` setup (existing keys, agent, -password). Anything after `--` is passed to `ssh` unchanged. +- 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; +- uploads the new file as `~/.ssh/authorized_keys.keyfunc-` and sets it + to mode `0600`; +- renames that file over `~/.ssh/authorized_keys`. + +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. + +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`. + +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 +normal `ssh` setup, except that batch mode does not prompt: a key or an agent +has to do it, not a typed password. ### `keyfunc ssh to [ssh arguments...]` @@ -106,7 +204,9 @@ Derives the key, serves it from an SSH agent that runs inside the tool on a unix socket in a new private `0700` temporary directory, then runs the system `ssh` with `-o IdentityAgent=` followed by the host and all remaining arguments unchanged. The tool exits with `ssh`'s exit status and removes the -socket and directory on the way out. The private key is never written to disk. +socket and directory on the way out. The private key is never written to disk. A +SIGINT, SIGTERM or SIGHUP ends `ssh` and still removes the socket and directory, +and the tool then exits with status 1 unless `ssh` reported one of its own. ## age identities: `keyfunc age` @@ -116,6 +216,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. @@ -148,33 +257,64 @@ 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: most Makefile targets are thin shims over an executable in +`script/` (`build` and `clean` are the exceptions). -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: + +- [#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) + +## License + +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). diff --git a/main.go b/cmd/keyfunc/main.go similarity index 100% rename from main.go rename to cmd/keyfunc/main.go diff --git a/go.mod b/go.mod index 4a2f2ce..092d55c 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,27 @@ module git.eeqj.de/sneak/keyfunc -go 1.26 +go 1.26.0 require ( - filippo.io/age v1.2.1 + filippo.io/age v1.3.2 git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd - github.com/btcsuite/btcd v0.24.2 - github.com/btcsuite/btcd/btcutil v1.1.6 - github.com/spf13/cobra v1.9.1 - github.com/stretchr/testify v1.8.4 + github.com/btcsuite/btcd v0.25.0 + github.com/btcsuite/btcd/btcutil v1.2.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 github.com/tyler-smith/go-bip39 v1.1.0 - golang.org/x/crypto v0.38.0 - golang.org/x/term v0.32.0 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 ) require ( - github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + filippo.io/hpke v0.4.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.5.0 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/sys v0.33.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee // indirect + github.com/spf13/pflag v1.0.9 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 4915728..57e7f4b 100644 --- a/go.sum +++ b/go.sum @@ -1,136 +1,50 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= -filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= -filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs= +c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c= +filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd h1:6YFV6horz2wDFPWWhour8qx8gLGyO0qoplwEeOuQ2J4= git.eeqj.de/sneak/secret v0.0.0-20260810132333-41cea400a7fd/go.mod h1:gKCcMZvlBOqusn/BxR8IyFmSJQr6R4vvjJ926iNpOSI= -github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= -github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= -github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= -github.com/btcsuite/btcd v0.24.2 h1:aLmxPguqxza+4ag8R1I2nnJjSu2iFn/kqtHTIImswcY= -github.com/btcsuite/btcd v0.24.2/go.mod h1:5C8ChTkl5ejr3WHj8tkQSCmydiMEPB0ZhQhehpq7Dgg= -github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= -github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= -github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= -github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= -github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= -github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= -github.com/btcsuite/btcd/btcutil v1.1.6 h1:zFL2+c3Lb9gEgqKNzowKUPQNb8jV7v5Oaodi/AYFd6c= -github.com/btcsuite/btcd/btcutil v1.1.6/go.mod h1:9dFymx8HpuLqBnsPELrImQeTQfKBQqzqGbbV3jK55aE= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= -github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= -github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= -github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= -github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/btcsuite/btcd v0.25.0 h1:JPbjwvHGpSywBRuorFFqTjaVP4y6Qw69XJ1nQ6MyWJM= +github.com/btcsuite/btcd v0.25.0/go.mod h1:qbPE+pEiR9643E1s1xu57awsRhlCIm1ZIi6FfeRA4KE= +github.com/btcsuite/btcd/btcec/v2 v2.5.0 h1:KioMXOWa76b86sTZZOmbzv/ldaQCmB8KFAyn5PbB8E8= +github.com/btcsuite/btcd/btcec/v2 v2.5.0/go.mod h1:+K/MYXcLBtHEQjRbjHuJChuybk4LCgjdjgRwil+e+Kk= +github.com/btcsuite/btcd/btcutil v1.2.0 h1:p3+S2g3Q+7G5NOh4Ji+2UrBOrg5Z0Q4ykzShWG1Dhgs= +github.com/btcsuite/btcd/btcutil v1.2.0/go.mod h1:/Taflm113pYjUpbWKKQEfa6XOtI/+WS8awxeMZpY75k= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0 h1:yMIg99+4aBvqfl/HzJRKfxTX9rGfikoI9uvFzterhc8= +github.com/btcsuite/btcd/chaincfg/chainhash v1.2.0/go.mod h1:Y72Ren9gfhlEvnwnT78BGcSNO2UMphTKLn9AorF+5rg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= -github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee h1:FPP9HDkBbPyniu+u7FHZg+kKFX1WW0gxOGteJ0h3AJk= +github.com/kcalvinalvin/anet v0.0.0-20251112173137-d8ddc1f6dbee/go.mod h1:N6sz6HwJAenJ6d+/xmSl0ikfV05ZrVGmjt1ryy/WOtE= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= -golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6d63d0..2dd443e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,9 +2,13 @@ package cli import ( + "context" "errors" "fmt" "os" + "os/signal" + "runtime/debug" + "syscall" "git.eeqj.de/sneak/keyfunc/internal/cli/age" "git.eeqj.de/sneak/keyfunc/internal/cli/mnemonic" @@ -13,20 +17,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, } @@ -42,8 +69,19 @@ func Root() *cobra.Command { // status of its own, which "ssh to" uses to hand on the status ssh // ended with. ssh has already said whatever it had to say in that // case, so nothing more is printed. +// +// SIGINT, SIGTERM and SIGHUP cancel the command's context instead of +// killing the process outright, so the child ssh or sftp ends and the +// deferred cleanup that removes the agent socket and the install +// working directory still runs. func Main() int { - err := Root().Execute() + ctx, stop := signal.NotifyContext( + context.Background(), + syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, + ) + defer stop() + + err := Root().ExecuteContext(ctx) if err == nil { return 0 } diff --git a/internal/cli/ssh/install.go b/internal/cli/ssh/install.go index 8133261..761fe3c 100644 --- a/internal/cli/ssh/install.go +++ b/internal/cli/ssh/install.go @@ -1,57 +1,48 @@ package ssh import ( + "bytes" + "crypto/rand" + "encoding/hex" "fmt" + "os" "os/exec" + "path/filepath" "slices" "strings" "github.com/spf13/cobra" ) -// script is what runs on the host. It reads the key line from its own -// standard input, so the line never appears on a command line, where -// anyone else on the host could read it out of the process list. It -// contains no single quote, so the whole of it travels through ssh -// inside one pair of them. The umask keeps anything it makes to the -// owner from the start; the modes are then set outright, whatever the -// umask on the host turns out to be. A file whose last line has no -// newline at its end gets one before the key line goes on, so that the -// two do not run into each other. -const script = ` -set -e -umask 077 -directory="$HOME/.ssh" -file="$directory/authorized_keys" -if [ ! -d "$directory" ]; then - mkdir -p "$directory" - chmod 700 "$directory" -fi -if [ ! -f "$file" ]; then - : > "$file" - chmod 600 "$file" -fi -IFS= read -r line -if grep -q -x -F -e "$line" "$file"; then - echo "already present" -else - if [ -s "$file" ] && [ -n "$(tail -c 1 "$file")" ]; then - printf "\n" >> "$file" - fi - printf "%s\n" "$line" >> "$file" - echo "added" -fi -` +// Where the key goes on the host and what the file it arrives in is +// called before it is renamed into place. The random end of that name +// keeps two runs at once from writing to the same file. +const ( + directory = ".ssh" + authorized = ".ssh/authorized_keys" + sidecarPrefix = ".ssh/authorized_keys.keyfunc-" + sidecarBytes = 8 +) + +// The modes the host is left with, as sftp's chmod spells them, and +// the mode of the copy made here on the way. +const ( + directoryMode = "700" + fileMode = "600" + localMode = 0o600 +) // install returns the command that adds the public key to a host. func install() *cobra.Command { cmd := &cobra.Command{ - Use: "install <[user@]host> [-- ssh options...]", + Use: "install <[user@]host> [-- sftp options...]", Short: "add the public key to a host's authorized_keys", - Long: "Runs the system ssh to the host, which makes ~/.ssh and " + - "~/.ssh/authorized_keys there if they are missing and adds " + - "the public key unless the same line is already in the " + - "file. Anything after -- is given to ssh unchanged.", + Long: "Downloads the host's authorized_keys with the system " + + "sftp, adds the public key to it here unless the same " + + "line is already there, and uploads the result as a file " + + "beside it which is then renamed over it. Nothing is run " + + "on the host. Anything after -- is given to sftp " + + "unchanged, which is where the port goes (-P).", Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key, comment, err := derived(cmd) @@ -64,7 +55,7 @@ func install() *cobra.Command { return err } - return send(cmd, args[0], args[1:], line) + return add(cmd, args[0], args[1:], line) }, } @@ -73,24 +64,271 @@ func install() *cobra.Command { return cmd } -// send runs ssh to the host with the user's options, gives it the -// script to run there, and writes the key line to its standard input. -// What the host says, added or already present, is passed straight on. -func send(cmd *cobra.Command, host string, options []string, line string) error { - argv := slices.Concat(options, []string{ - host, "/bin/sh -c '" + script + "'", - }) - - //nolint:gosec // the options are the user's own, meant for ssh - command := exec.CommandContext(cmd.Context(), "ssh", argv...) - command.Stdin = strings.NewReader(line + "\n") - command.Stdout = cmd.OutOrStdout() - command.Stderr = cmd.ErrOrStderr() - - err := command.Run() +// add puts the key line in the host's authorized_keys. The file is +// fetched in one sftp session and written back in another, so a run +// that adds a line connects twice; a run that finds the line already +// there connects once and stops. +func add(cmd *cobra.Command, host string, options []string, line string) error { + work, err := os.MkdirTemp("", "keyfunc-install-") if err != nil { - return fmt.Errorf("running ssh: %w", err) + return fmt.Errorf("making a temporary directory: %w", err) } - return nil + defer func() { _ = os.RemoveAll(work) }() + + content, present, err := fetch(cmd, host, options, + filepath.Join(work, "authorized_keys"), + ) + if err != nil { + return err + } + + merged, added := merge(content, line) + if !added { + return write(cmd, "already present\n") + } + + return upload(cmd, host, options, work, merged, present) +} + +// 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. +func upload( + cmd *cobra.Command, host string, options []string, + work, merged string, present bool, +) error { + local := filepath.Join(work, "authorized_keys.merged") + + err := os.WriteFile(local, []byte(merged), localMode) + if err != nil { + return fmt.Errorf("writing the new file: %w", err) + } + + sidecar, err := sidecarName() + if err != nil { + 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) + 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 + // the put was reached, which is where a file of that name + // can be on the host. Before that there is none to name. + if strings.Contains(said, sidecar) { + return fmt.Errorf( + "%w; %s may be left on the host", err, sidecar, + ) + } + + return err + } + + return write(cmd, "added\n") +} + +// session runs one sftp session with the user's own options and the +// batch of commands, which sftp reads from its standard input and +// stops at the first of which that fails, unless it begins with a +// dash. sftp echoes the commands as it runs them, so everything it +// says goes to the error output and the tool's own output stays the +// one word it prints. What it said is also given back: a session that +// failed says there what went wrong, and the status alone does not. +func session( + cmd *cobra.Command, host string, options []string, batch []string, +) (string, error) { + argv := slices.Concat( + []string{"-b", "-"}, options, []string{host}, + ) + + var said bytes.Buffer + + //nolint:gosec // the options are the user's own, meant for sftp + command := exec.CommandContext(cmd.Context(), "sftp", argv...) + command.Env = childEnv() + command.Stdin = strings.NewReader(strings.Join(batch, "\n") + "\n") + command.Stdout = &said + command.Stderr = &said + + err := command.Run() + + _, _ = cmd.ErrOrStderr().Write(said.Bytes()) + + if err != nil { + return said.String(), fmt.Errorf("running sftp: %w", err) + } + + return said.String(), nil +} + +// merge returns the file with the key line on the end, and whether it +// had to be added. A file whose last line has no newline at its end +// gets one first, so that the two lines do not run into each other. +func merge(content, line string) (string, bool) { + if slices.Contains(strings.Split(content, "\n"), line) { + return content, false + } + + if content != "" && !strings.HasSuffix(content, "\n") { + content += "\n" + } + + return content + line + "\n", true +} + +// 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. +func fetch( + cmd *cobra.Command, host string, options []string, into string, +) (string, bool, 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 "", false, 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 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 +} + +// absent says whether sftp reported the file that was asked for as +// not being there, which is the one failure of the fetch that is read +// as an empty authorized_keys. The reading is taken only from the +// line in which sftp reports on that file, because ssh writes "no +// such file" into the same output for reasons of its own — a missing +// -i identity file draws that warning on a session that then +// authenticates through the agent — and a real read failure on such a +// session must not pass for an empty file. +func absent(said string) bool { + for line := range strings.Lines(said) { + named, is := reportedNotFound(strings.TrimSpace(line)) + if is && (named == authorized || + strings.HasSuffix(named, "/"+authorized)) { + return true + } + } + + return false +} + +// reportedNotFound returns the path an sftp line reports as not being +// there, and whether the line is such a report. The client writes one +// wording for a remote file it cannot find, naming the path the +// server expanded, which is the absolute one. +func reportedNotFound(line string) (string, bool) { + const ( + before = `File "` + after = `" not found.` + ) + + if !strings.HasPrefix(line, before) || + !strings.HasSuffix(line, after) { + return "", false + } + + return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true +} + +// sidecarName returns the name the new file is uploaded under. +func sidecarName() (string, error) { + random := make([]byte, sidecarBytes) + + _, err := rand.Read(random) + if err != nil { + return "", fmt.Errorf("making a name for the new file: %w", err) + } + + return sidecarPrefix + hex.EncodeToString(random), nil +} + +// quoted puts the double quotes around a path that sftp needs when the +// path has a space in it. Only paths of the tool's own making are +// given to it, and they hold no quote of their own. +func quoted(path string) string { + return `"` + path + `"` } diff --git a/internal/cli/ssh/install_test.go b/internal/cli/ssh/install_test.go new file mode 100644 index 0000000..09d53de --- /dev/null +++ b/internal/cli/ssh/install_test.go @@ -0,0 +1,133 @@ +//nolint:testpackage // absent is what these wordings are read by +package ssh + +import "testing" + +// What a session says besides its report on the file that was asked +// for: sftp echoes the command it is running, and ssh warns about an +// identity file it cannot find in the words of a missing file even +// though the session goes on to authenticate. +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" +) + +// TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys holds the +// wordings the OpenSSH client was seen to use against a real server: +// a file it cannot find is reported one way, naming the path the +// server expanded, and everything else it says is a failure. +func TestAbsenceIsReadOnlyFromWhatSFTPSaidAboutAuthorizedKeys(t *testing.T) { + t.Parallel() + + sessions := map[string]struct { + said string + want bool + }{ + "the file is not there": { + said: echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there, named as it was asked for": { + said: echoed + `File ".ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is not there and an identity file is not either": { + said: warning + echoed + + `File "/home/someone/.ssh/authorized_keys" not found.` + "\n", + want: true, + }, + "the file is there and cannot be read": { + said: echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "only an identity file is not there": { + said: warning + echoed + + `remote open "/home/someone/.ssh/authorized_keys": ` + + "Permission denied\n", + want: false, + }, + "some other file is not there": { + said: echoed + `File "/home/someone/.ssh/known_hosts" 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, session := range sessions { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if absent(session.said) != session.want { + t.Errorf( + "read as absent: %t, wanted %t, from:\n%s", + !session.want, session.want, session.said, + ) + } + }) + } +} + +// 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, + ) + } + }) + } +} diff --git a/internal/cli/ssh/ssh.go b/internal/cli/ssh/ssh.go index b15a08b..3df7344 100644 --- a/internal/cli/ssh/ssh.go +++ b/internal/cli/ssh/ssh.go @@ -3,9 +3,12 @@ package ssh import ( "fmt" + "os" + "strings" "git.eeqj.de/sneak/keyfunc/internal/cli/options" "git.eeqj.de/sneak/keyfunc/internal/derive" + "git.eeqj.de/sneak/keyfunc/internal/mnemonic" "git.eeqj.de/sneak/keyfunc/internal/sshkey" "github.com/spf13/cobra" ) @@ -84,6 +87,26 @@ func write(cmd *cobra.Command, text string) error { return nil } +// childEnv is the tool's environment with the mnemonic variables taken +// out, for the ssh and sftp children it starts. "ssh to" exists so the +// private key never leaves the tool; the mnemonic, from either variable, +// must not leave it either. +func childEnv() []string { + environ := os.Environ() + kept := make([]string, 0, len(environ)) + + for _, entry := range environ { + name, _, _ := strings.Cut(entry, "=") + if name == mnemonic.Variable || name == mnemonic.CommandVariable { + continue + } + + kept = append(kept, entry) + } + + return kept +} + // addComment gives a command its comment flag. func addComment(cmd *cobra.Command) { cmd.Flags().String( diff --git a/internal/cli/ssh/to.go b/internal/cli/ssh/to.go index 8dc5149..6569c5c 100644 --- a/internal/cli/ssh/to.go +++ b/internal/cli/ssh/to.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "slices" + "syscall" "github.com/spf13/cobra" ) @@ -70,10 +71,18 @@ func to() *cobra.Command { func connect(ctx context.Context, argv []string) error { //nolint:gosec // the arguments are the user's own, meant for ssh command := exec.CommandContext(ctx, "ssh", argv...) + command.Env = childEnv() command.Stdin = os.Stdin command.Stdout = os.Stdout command.Stderr = os.Stderr + // A cancelled context means a signal ended the tool. Send ssh a + // SIGTERM rather than the default kill, so it puts the terminal + // back the way it found it before it goes. + command.Cancel = func() error { + return command.Process.Signal(syscall.SIGTERM) + } + err := command.Run() if err == nil { return nil diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 6b787fc..1bf8193 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -1,11 +1,16 @@ package cli_test import ( + "bytes" "os" + "os/exec" "path/filepath" + "slices" "strconv" "strings" + "syscall" "testing" + "time" "git.eeqj.de/sneak/keyfunc/internal/cli" "git.eeqj.de/sneak/keyfunc/internal/cli/ssh" @@ -13,8 +18,25 @@ import ( "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 -// stand-in ssh needs so that it can be run at all. +// stand-ins need so that they can be run at all. const ( directoryMode = 0o700 fileMode = 0o600 @@ -22,8 +44,21 @@ const ( ) // failingStatus is the status the stand-in ssh ends with when a test -// wants to see a status handed on. -const failingStatus = 7 +// wants to see a status handed on, and failedStatus is the status the +// tool itself ends with when something went wrong. +const ( + failingStatus = 7 + failedStatus = 1 +) + +// notADirectory is what a test puts where the .ssh directory belongs +// to make a step of the write session fail. +const notADirectory = "a file where the directory belongs\n" + +// missingIdentity is a path with no file at it, handed to sftp after +// the dashes so that ssh warns about it in the words of a missing +// file. +const missingIdentity = "/nonexistent/keyfunc-test-identity" // The host, and where on it the key ends up. const ( @@ -32,27 +67,118 @@ const ( keptIn = "authorized_keys" ) -// installer is a stand-in for the system ssh for the install command. -// It writes down what it was given and then runs the command meant for -// the host right here, with the home directory pointed at a directory -// standing in for the host's, so that what keyfunc sends can be -// watched doing its work. +// remoteCommand is the command the "to" tests hand ssh after the host. +const remoteCommand = "uptime" + +// The tool's own name, as it stands in the arguments a test hands to +// Main, the ssh subcommand both commands the tests here drive live +// under, and the one of those two these tests name most. +const ( + tool = "keyfunc" + subcommand = "ssh" + installing = "install" +) + +// The key line the example mnemonic gives at index 0, as it stands in +// an authorized_keys file. +const keyLine = vectorZero + " keyfunc/ssh/0\n" + +// marker is a variable set beside the mnemonic ones and expected to +// reach the stand-in, so a scrubbed environment is told apart from an +// empty one. +const marker = "KEYFUNC_TEST_MARKER" + +// installer is a stand-in for the system sftp for the install +// command. It writes down the arguments and every command of the +// batch it is given, echoes each command as sftp does, writes down its +// own environment when a test asks for it, and carries the commands out +// against a directory standing in for the host's home directory, so that +// what keyfunc sends can be watched doing its 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. const installer = ` -while [ $# -gt 1 ]; do - printf '%s\n' "$1" >> "$KEYFUNC_TEST_ARGUMENTS" - shift +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" +previous= +for argument in "$@"; do + printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" + if [ "$previous" = -i ] && [ ! -e "$argument" ]; then + printf 'Warning: Identity file %s not accessible: %s.\n' \ + "$argument" "No such file or directory" >&2 + fi + previous=$argument +done +home="$KEYFUNC_TEST_HOME" +while IFS= read -r line; do + printf 'sftp> %s\n' "$line" + printf '%s\n' "$line" >> "$KEYFUNC_TEST_BATCH" + allowed=no + case "$line" in + -*) + line=${line#-} + allowed=yes + ;; + esac + 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 + printf 'File "%s" not found.\n' "$home/$2" >&2 + elif ! cp "$home/$2" "$3" 2>/dev/null; then + worked=no + printf 'remote open "%s": Permission denied\n' "$home/$2" >&2 + fi + ;; + put) cp "$2" "$home/$3" 2>/dev/null || worked=no ;; + mkdir) mkdir "$home/$2" 2>/dev/null || worked=no ;; + chmod) chmod "$2" "$home/$3" 2>/dev/null || worked=no ;; + rename) mv "$home/$2" "$home/$3" 2>/dev/null || worked=no ;; + esac + if [ "$worked" = no ] && [ "$allowed" = no ]; then + printf 'sftp: %s failed\n' "$1" >&2 + exit 1 + fi done -printf '%s' "$1" > "$KEYFUNC_TEST_COMMAND" -HOME="$KEYFUNC_TEST_HOME" -export HOME -eval "$1" ` // caller is a stand-in for the system ssh for the to command. It // writes down the arguments it was given, notes the agent socket if -// there really is one at the path it was handed, and ends with the -// status the test asked for. +// there really is one at the path it was handed, writes down its own +// environment when a test asks for it, and ends with the status the +// test asked for. const caller = ` +[ -n "$KEYFUNC_TEST_ENVIRONMENT" ] && env > "$KEYFUNC_TEST_ENVIRONMENT" for argument in "$@"; do printf '%s\n' "$argument" >> "$KEYFUNC_TEST_ARGUMENTS" done @@ -63,24 +189,34 @@ fi exit "$KEYFUNC_TEST_STATUS" ` -// pretended is where a stand-in ssh writes down what it was asked to -// do. +// sleeper is a stand-in for the system ssh that notes the agent socket +// and then blocks, so a test can cancel the context while it is running +// and watch the tool take the agent down. The wait ends on its own only +// as a backstop, well after the test has cancelled and looked. +const sleeper = ` +socket=${2#IdentityAgent=} +if [ -S "$socket" ]; then + printf '%s\n' "$socket" > "$KEYFUNC_TEST_SOCKET" +fi +sleep 5 +` + +// pretended is where a stand-in writes down what it was asked to do. type pretended struct { // home stands in for the home directory on the host. home string - // arguments holds what ssh was given before the command, one per - // line. + // arguments holds the arguments of every session, one per line. arguments string - // command holds what ssh was told to run on the host. - command string + // batch holds the commands of every session, one per line. + batch string } -func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { +func TestTheKeyIsAddedToAHostThatHasNoFileYet(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - require.Equal(t, "added\n", run(t, "ssh", "install", host)) + require.Equal(t, "added\n", install(t, host)) directory, err := os.Stat(filepath.Join(pretend.home, keptUnder)) require.NoError(t, err) @@ -94,11 +230,30 @@ func TestTheKeyIsAddedToTheHostAndThenLeftAlone(t *testing.T) { require.NoError(t, err) require.Equal(t, os.FileMode(fileMode), file.Mode().Perm()) - added := read(t, path) - require.Equal(t, vectorZero+" keyfunc/ssh/0\n", added) + require.Equal(t, keyLine, read(t, path)) +} - require.Equal(t, "already present\n", run(t, "ssh", "install", host)) - require.Equal(t, added, read(t, path)) +func TestAKeyThatIsAlreadyThereIsLeftAlone(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "somebody else\n"+keyLine) + + 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)) +} + +func TestAnEmptyFileGetsTheKeyAndNoBlankLineBeforeIt(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + path := seed(t, pretend, "") + + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, keyLine, read(t, path)) } func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { @@ -106,41 +261,192 @@ func TestTheKeyDoesNotRunIntoALineWithNoNewlineAtItsEnd(t *testing.T) { pretend := pretendHost(t) already := "ssh-ed25519 AAAAsomebodyelse somebody@else" + path := seed(t, pretend, already) - require.NoError(t, - os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), - ) - - path := filepath.Join(pretend.home, keptUnder, keptIn) - require.NoError(t, os.WriteFile(path, []byte(already), fileMode)) - - require.Equal(t, "added\n", run(t, "ssh", "install", host)) - require.Equal(t, - already+"\n"+vectorZero+" keyfunc/ssh/0\n", - read(t, path), - ) + require.Equal(t, "added\n", install(t, host)) + require.Equal(t, already+"\n"+keyLine, read(t, path)) } -func TestTheKeyLineIsNotOnTheCommandLine(t *testing.T) { +func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host) + require.Equal(t, "added\n", install(t, host)) + + sent := recorded(t, pretend.batch) + require.Len(t, sent, 6) + + // The name of the uploaded file is random, so it is read off the + // put and then looked for in the two commands that follow. + beside := strings.Fields(sent[3])[2] + require.True(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.Equal(t, "-mkdir .ssh", sent[1]) + require.Equal(t, "chmod 700 .ssh", sent[2]) + require.Equal(t, "put", strings.Fields(sent[3])[0]) + require.Equal(t, "chmod 600 "+beside, sent[4]) + require.Equal(t, "rename "+beside+" .ssh/authorized_keys", sent[5]) +} + +func TestAFileThatCannotBeReadIsNotWrittenOver(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + 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, and what was on the host is + // still what is on the host. + require.Equal(t, 1, connections(t, pretend)) + 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()) + + pretend := pretendHost(t) + unreadable := unfetchable(t, pretend) + + // ssh warns about an -i it cannot find in the words of a missing + // file, on a session that then authenticates perfectly well. That + // warning is not sftp reporting on authorized_keys, so the fetch + // failure is still a failure. + printed, said, err := attempt(t, host, "--", "-i", missingIdentity) + require.Error(t, err) + require.Empty(t, printed) + require.Contains(t, said, "No such file or directory") + require.Contains(t, said, "Permission denied") + + require.Equal(t, 1, connections(t, pretend)) + require.DirExists(t, unreadable) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{ + tool, subcommand, installing, host, "--", "-i", missingIdentity, + } + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestAFailedStepNamesTheUploadedFileAndChangesNothing(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + 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. + inTheWay := filepath.Join(pretend.home, keptUnder) + require.NoError(t, + os.WriteFile(inTheWay, []byte(notADirectory), fileMode), + ) + + printed, said, err := attempt(t, host) + require.Error(t, err) + 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. + 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.Equal(t, notADirectory, read(t, inTheWay)) + + // The same run again, this way for the status it ends with. + given := os.Args + + t.Cleanup(func() { os.Args = given }) + + os.Args = []string{tool, subcommand, installing, host} + + require.Equal(t, failedStatus, cli.Main()) +} + +func TestTheKeyLineIsNotSentAsACommand(t *testing.T) { + t.Setenv(mnemonic.Variable, example()) + + pretend := pretendHost(t) + + install(t, host) require.NotContains(t, read(t, pretend.arguments), "ssh-ed25519") - require.NotContains(t, read(t, pretend.command), "ssh-ed25519") + require.NotContains(t, read(t, pretend.batch), "ssh-ed25519") } -func TestWhatComesAfterTheDashesIsGivenToSSH(t *testing.T) { +func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) { t.Setenv(mnemonic.Variable, example()) pretend := pretendHost(t) - run(t, "ssh", "install", host, "--", "-p", "2222") + install(t, host, "--", "-P", "2222") + // The same arguments twice over: adding a line takes two + // connections, one to fetch the file and one to write it back. + session := []string{"-b", "-", "-P", "2222", host} require.Equal(t, - []string{"-p", "2222", host}, + slices.Concat(session, session), recorded(t, pretend.arguments), ) } @@ -150,7 +456,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { arguments, noted := pretendCall(t) - _, err := execute(t, "ssh", "to", host, "uptime") + _, err := execute(t, subcommand, "to", host, remoteCommand) var passed ssh.StatusError @@ -159,7 +465,7 @@ func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) { given := recorded(t, arguments) require.Equal(t, "-o", given[0]) - require.Equal(t, []string{host, "uptime"}, given[2:]) + require.Equal(t, []string{host, remoteCommand}, given[2:]) // The stand-in wrote the path down only because there really was // a socket there while it ran. @@ -177,11 +483,130 @@ func TestTheToolEndsWithTheStatusSSHEndedWith(t *testing.T) { t.Cleanup(func() { os.Args = given }) - os.Args = []string{"keyfunc", "ssh", "to", host, "uptime"} + os.Args = []string{tool, subcommand, "to", host, remoteCommand} require.Equal(t, failingStatus, cli.Main()) } +func TestASignalTakesTheAgentDirectoryDown(t *testing.T) { + 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") + t.Setenv("KEYFUNC_TEST_SOCKET", noted) + standIn(t, "ssh", sleeper) + + //nolint:gosec // the binary is this test's own, re-run as the tool + command := exec.CommandContext( + t.Context(), os.Args[0], subcommand, "to", host, remoteCommand, + ) + + command.Env = append(os.Environ(), runAsTool+"=1") + require.NoError(t, command.Start()) + + // The stand-in notes the socket only once the agent is up and ssh + // 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) + go func() { done <- command.Wait() }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatalf("the tool did not end after %s", name) + } +} + +// waitForSocket waits for the stand-in to write down the agent socket +// and gives back the path, which means the agent is up and ssh is +// running against it. +func waitForSocket(t *testing.T, noted string) string { + t.Helper() + + var socket string + + require.Eventually(t, func() bool { + content, err := os.ReadFile(noted) //nolint:gosec // test path + if err != nil { + return false + } + + socket = strings.TrimSpace(string(content)) + + return socket != "" + }, 5*time.Second, 5*time.Millisecond) + + return socket +} + +func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendHost(t) + environment := recordEnvironment(t) + + install(t, host) + + mnemonicWithheld(t, read(t, environment)) +} + +func TestTheMnemonicIsNotHandedToSSH(t *testing.T) { + t.Setenv(mnemonic.CommandVariable, "echo "+example()) + t.Setenv(mnemonic.Variable, example()) + t.Setenv(marker, "reaches the stand-in") + + pretendCall(t) + environment := recordEnvironment(t) + + _, err := execute(t, subcommand, "to", host, remoteCommand) + + var passed ssh.StatusError + + require.ErrorAs(t, err, &passed) + + mnemonicWithheld(t, read(t, environment)) +} + // pretendHost puts the install stand-in on the path and gives back the // places it writes to. func pretendHost(t *testing.T) pretended { @@ -190,17 +615,110 @@ func pretendHost(t *testing.T) pretended { pretend := pretended{ home: t.TempDir(), arguments: filepath.Join(t.TempDir(), "arguments"), - command: filepath.Join(t.TempDir(), "command"), + batch: filepath.Join(t.TempDir(), "batch"), } t.Setenv("KEYFUNC_TEST_HOME", pretend.home) t.Setenv("KEYFUNC_TEST_ARGUMENTS", pretend.arguments) - t.Setenv("KEYFUNC_TEST_COMMAND", pretend.command) - standIn(t, installer) + t.Setenv("KEYFUNC_TEST_BATCH", pretend.batch) + standIn(t, "sftp", installer) return pretend } +// install runs the install command, requires it to have worked, and +// gives back what the tool itself printed. +func install(t *testing.T, args ...string) string { + t.Helper() + + printed, _, err := attempt(t, args...) + require.NoError(t, err) + + return printed +} + +// attempt runs the install command with the tool's own output kept +// apart from what the stand-in said, since the stand-in echoes its +// batch as sftp does. It gives back what the tool printed, what the +// stand-in said, and how the run ended. +func attempt(t *testing.T, args ...string) (string, string, error) { + t.Helper() + + var printed, said bytes.Buffer + + root := cli.Root() + root.SetOut(&printed) + root.SetErr(&said) + root.SetArgs(slices.Concat([]string{subcommand, installing}, args)) + + err := root.ExecuteContext(t.Context()) + + return printed.String(), said.String(), err +} + +// seed puts an authorized_keys file on the stand-in host before the +// tool runs and gives back its path. +func seed(t *testing.T, pretend pretended, content string) string { + t.Helper() + + directory := filepath.Join(pretend.home, keptUnder) + require.NoError(t, os.Mkdir(directory, directoryMode)) + + path := filepath.Join(directory, keptIn) + require.NoError(t, os.WriteFile(path, []byte(content), fileMode)) + + return path +} + +// unfetchable puts a directory where authorized_keys belongs on the +// stand-in host, which the stand-in can see but cannot fetch: that is +// how a file that is there and cannot be read looks from here. It +// gives back the path. +func unfetchable(t *testing.T, pretend pretended) string { + t.Helper() + + require.NoError(t, + os.Mkdir(filepath.Join(pretend.home, keptUnder), directoryMode), + ) + + path := filepath.Join(pretend.home, keptUnder, keptIn) + require.NoError(t, os.Mkdir(path, directoryMode)) + + 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. @@ -213,20 +731,21 @@ func pretendCall(t *testing.T) (string, string) { t.Setenv("KEYFUNC_TEST_ARGUMENTS", arguments) t.Setenv("KEYFUNC_TEST_SOCKET", noted) t.Setenv("KEYFUNC_TEST_STATUS", strconv.Itoa(failingStatus)) - standIn(t, caller) + standIn(t, "ssh", caller) return arguments, noted } -// standIn writes a stand-in for the system ssh and puts it first on -// the path, so that the tool finds it instead of the real one. -func standIn(t *testing.T, body string) { +// standIn writes a stand-in for one of the system programs and puts it +// first on the path, so that the tool finds it instead of the real +// one. +func standIn(t *testing.T, name, body string) { t.Helper() directory := t.TempDir() err := os.WriteFile( - filepath.Join(directory, "ssh"), + filepath.Join(directory, name), []byte("#!/bin/sh\n"+body), standInMode, ) require.NoError(t, err) @@ -236,6 +755,28 @@ func standIn(t *testing.T, body string) { ) } +// recordEnvironment asks the stand-in to write its environment down and +// gives back the file it writes it to. +func recordEnvironment(t *testing.T) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "environment") + t.Setenv("KEYFUNC_TEST_ENVIRONMENT", path) + + return path +} + +// mnemonicWithheld requires that neither mnemonic variable reached the +// stand-in and that the marker set beside them did, so an empty +// environment does not pass for a scrubbed one. +func mnemonicWithheld(t *testing.T, environment string) { + t.Helper() + + require.NotContains(t, environment, mnemonic.Variable+"=") + require.NotContains(t, environment, mnemonic.CommandVariable+"=") + require.Contains(t, environment, marker+"=") +} + // read returns what is in a file. func read(t *testing.T, path string) string { t.Helper() @@ -247,7 +788,7 @@ func read(t *testing.T, path string) string { return string(content) } -// recorded returns the arguments a stand-in wrote down, one per line. +// recorded returns the lines a stand-in wrote down. func recorded(t *testing.T, path string) []string { t.Helper() diff --git a/internal/cli/version_internal_test.go b/internal/cli/version_internal_test.go new file mode 100644 index 0000000..9c01c91 --- /dev/null +++ b/internal/cli/version_internal_test.go @@ -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)) + }) +}