Compare commits
10
Commits
9ad3bbd347
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
319630a684 | ||
|
|
ccdc576cd3 | ||
|
|
44714205d4 | ||
|
|
e82c91d27c | ||
|
|
b0e018f0b6 | ||
|
|
56e20b66e4 | ||
|
|
5b36e42e4d | ||
|
|
dad29597bd | ||
|
|
c96b77dd67 | ||
|
|
1d1c8182be |
+13
-6
@@ -18,9 +18,16 @@
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
# Each submodule keeps a config with the same exposure in its git directory
|
||||
# under .git/modules/, nested again for a submodule's own submodules.
|
||||
.git/config
|
||||
.git/modules/**/config
|
||||
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||
# its own .git directory when it keeps one.
|
||||
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||
# `**/.git/modules/**/config` also matches that segment's directory
|
||||
# under .git/modules/. Go's version stamping then fails the build;
|
||||
# nothing leaks. Name such a submodule without that segment:
|
||||
# `git submodule add --name`.
|
||||
**/.git/config
|
||||
**/.git/modules/**/config
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
@@ -44,7 +51,9 @@
|
||||
**/[iI][dD]_[rR][sS][aA]
|
||||
**/[iI][dD]_[dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
@@ -62,7 +71,5 @@
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# The binary `make build` writes, and the CI workflow, which is not a
|
||||
# build input.
|
||||
# The binary `make build` writes.
|
||||
/keyfunc
|
||||
.gitea
|
||||
|
||||
@@ -42,7 +42,9 @@ node_modules/
|
||||
[iI][dD]_[rR][sS][aA]
|
||||
[iI][dD]_[dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# The binary `make build` writes.
|
||||
/keyfunc
|
||||
|
||||
@@ -66,8 +66,11 @@ calls into `internal/`. The packages there are:
|
||||
- `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.
|
||||
`cli/options` holds the flags every command shares, `cli/signals` catches
|
||||
SIGINT, SIGTERM and SIGHUP for the commands that clean up before they end, and
|
||||
`cli/ssh`, `cli/age` and `cli/mnemonic` are the command groups.
|
||||
- `internal/bip39` is a copy of `github.com/tyler-smith/go-bip39` v1.1.0,
|
||||
trimmed to what keyfunc uses.
|
||||
|
||||
### Adding a key type
|
||||
|
||||
@@ -176,10 +179,13 @@ same way: one that cannot be read fails the first listing, and one that can be
|
||||
read but not entered fails the second, after which the tool says that `~/.ssh`
|
||||
cannot be entered. 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:
|
||||
find on a session that then authenticates through the agent. An
|
||||
`authorized_keys` that the first listing shows to be a symlink is refused and
|
||||
left as it is, since the rename below would replace the link itself and the file
|
||||
it points at would never get the key. 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;
|
||||
@@ -249,11 +255,21 @@ identity's own recipient, plus any given with `--to`, so the same mnemonic can
|
||||
always decrypt what it encrypted. Output goes to `-o` or standard output;
|
||||
`--armor` writes the text form. Nothing is written except the output.
|
||||
|
||||
A `-o` path that is the same file as the tool's own standard output or standard
|
||||
error, under any name such as `/dev/stdout` or `/dev/fd/2`, is written to that
|
||||
stream, as leaving out `-o` writes to standard output; the file the stream is
|
||||
redirected to is written as the redirect says and never replaced, so with `>>`
|
||||
the output follows what the file already held. Otherwise, a regular file already
|
||||
at the `-o` path is replaced, and the new file has mode `0600`. A symlink there
|
||||
is followed, and what it points at is treated the same way, so the link keeps
|
||||
pointing where it did; a symlink that points at nothing is refused. A named pipe
|
||||
or a device, such as `/dev/null`, is written to directly.
|
||||
|
||||
### `keyfunc age decrypt [-n N] [-o <file>] [<file>]`
|
||||
|
||||
Decrypts the file (or standard input) with the derived identity. Output goes to
|
||||
`-o` or standard output. If the identity is not one of the recipients, the tool
|
||||
says so and exits with status 1.
|
||||
`-o`, which is treated as for `encrypt`, or standard output. If the identity is
|
||||
not one of the recipients, the tool says so and exits with status 1.
|
||||
|
||||
## Derived mnemonics: `keyfunc mnemonic`
|
||||
|
||||
@@ -288,6 +304,21 @@ girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose
|
||||
Errors go to standard error and the exit status is 1, except for `ssh to`, which
|
||||
passes through `ssh`'s own exit status.
|
||||
|
||||
SIGINT, SIGTERM and SIGHUP end any command at once, at the mnemonic prompt too,
|
||||
with the status a shell gives a program killed by that signal (130 for SIGINT).
|
||||
While `age encrypt -o` or `age decrypt -o` is writing a new file or replacing a
|
||||
regular one, the signal makes it remove the unfinished file, leave a file
|
||||
already at the named path as it was, and exit with status 1. That holds for a
|
||||
signal that has reached `keyfunc` when its input ends; a later one leaves the
|
||||
whole file in place. Ctrl-C on a pipeline ends the input at the same moment, and
|
||||
on Linux `keyfunc` sees the signal first, though no system promises that. A
|
||||
named pipe or a device at the `-o` path, or a path that is the same file as
|
||||
standard output or standard error, is written to directly, and the signal ends
|
||||
the tool there as it ends any other command. While `ssh to` or `ssh install` has
|
||||
`ssh` or `sftp` running, the signal ends that program instead, the tool removes
|
||||
its agent socket or working files, and it exits with status 1, or for `ssh to`
|
||||
with `ssh`'s own status if `ssh` reported one.
|
||||
|
||||
## Entrypoints
|
||||
|
||||
The repo adheres to the
|
||||
@@ -344,9 +375,7 @@ standard: most Makefile targets are thin shims over an executable in `script/`
|
||||
|
||||
## TODO
|
||||
|
||||
The open issues that stand between the tree and a 1.0 release:
|
||||
|
||||
- [#42 go-bip39 no longer exists upstream: keep it, or copy it into the repo?](https://git.eeqj.de/sneak/keyfunc/issues/42)
|
||||
No issues are open.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+52
-16
@@ -104,10 +104,14 @@ style conventions are in separate documents:
|
||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||
image cannot be built unless they pass. For non-server repos the final stage
|
||||
brings up a development environment; for server repos it is the runtime image.
|
||||
Dockerfiles install development prerequisites by running `script/bootstrap`
|
||||
rather than duplicating installs inline; COPY `script/` and the dependency
|
||||
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before
|
||||
running it.
|
||||
The gate phases and the build stage start from their pinned base images and
|
||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||
repo's own `Dockerfile` does for its yarn packages. The development
|
||||
environment stage installs development prerequisites by running
|
||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||
|
||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||
@@ -156,6 +160,9 @@ style conventions are in separate documents:
|
||||
not evidence that anything ran: a sub-second build reporting success is a
|
||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||
and friends destroy a build cache shared with every other build on the host.
|
||||
When a check is added or changed, prove it works by planting a defect it must
|
||||
catch and watching the run fail on it, then revert the defect. A green run
|
||||
alone shows neither that the check ran nor that it covers what it should.
|
||||
|
||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
@@ -236,13 +243,28 @@ style conventions are in separate documents:
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint phase with `apk add`.
|
||||
- `.dockerignore` lets `.git` into the build context. It keeps out
|
||||
`.git/config` and each submodule's `config` under `.git/modules/` at any
|
||||
depth (`.git/modules/**/config`), which `git describe` does not need and
|
||||
which can hold a credential: a password in a remote URL, or the token the
|
||||
CI checkout step stores there. The stage that compiles has `git` (the
|
||||
- If the project requires CGO or system libraries for linting, install them
|
||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||
lists in the same `RUN`, so the layer does not keep them:
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||
submodule keeping its own `.git` directory. `git describe` does not need
|
||||
them, and each can hold a credential: a password in a remote URL, or the
|
||||
token the CI checkout step stores there. A submodule whose name has a
|
||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||
then fails the build: give it a name without that segment
|
||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||
takes the version from the `VERSION` build argument when one is given,
|
||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||
@@ -264,7 +286,12 @@ style conventions are in separate documents:
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build is uncached and so runs the gate phases a second time. That is the
|
||||
price of the rule above, and it is worth paying: the image that ships is built
|
||||
from a run of its own gates rather than from a cache entry.
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||
checked by review: to try a change to it, add the feature branch to that list
|
||||
and push, then remove the branch from the list again before merging. Keep any
|
||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||
from the feature branch publishes nothing.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -495,6 +522,11 @@ style conventions are in separate documents:
|
||||
|
||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||
|
||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
|
||||
@@ -607,10 +639,10 @@ style conventions are in separate documents:
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||
and language-specific config). Everything else goes in a subdirectory.
|
||||
Canonical subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||
@@ -641,3 +673,7 @@ style conventions are in separate documents:
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
|
||||
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||
is never committed under a file or directory named after one agent tool, such
|
||||
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||
|
||||
@@ -9,7 +9,6 @@ require (
|
||||
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.57.0
|
||||
golang.org/x/term v0.46.0
|
||||
)
|
||||
|
||||
@@ -35,16 +35,10 @@ github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3C
|
||||
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.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/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.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=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2018 Tyler Smith and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,268 @@
|
||||
// Package bip39 is the Golang implementation of the BIP39 spec.
|
||||
//
|
||||
// The official BIP39 spec can be found at
|
||||
// https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki
|
||||
//
|
||||
// It is a copy of github.com/tyler-smith/go-bip39 v1.1.0, trimmed to what
|
||||
// keyfunc uses.
|
||||
//
|
||||
//nolint:mnd // the numbers are BIP-39's own, written as upstream writes them
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidMnemonic is returned when trying to use a malformed mnemonic.
|
||||
ErrInvalidMnemonic = errors.New("invalid mnenomic")
|
||||
|
||||
// ErrEntropyLengthInvalid is returned when trying to use an entropy set with
|
||||
// an invalid size.
|
||||
ErrEntropyLengthInvalid = errors.New(
|
||||
"entropy length must be [128, 256] and a multiple of 32",
|
||||
)
|
||||
|
||||
// ErrChecksumIncorrect is returned when entropy has the incorrect checksum.
|
||||
ErrChecksumIncorrect = errors.New("checksum incorrect")
|
||||
)
|
||||
|
||||
// EntropyFromMnemonic takes a mnemonic generated by this library,
|
||||
// and returns the input entropy used to generate the given mnemonic.
|
||||
// An error is returned if the given mnemonic is invalid.
|
||||
func EntropyFromMnemonic(mnemonic string) ([]byte, error) {
|
||||
mnemonicSlice, isValid := splitMnemonicWords(mnemonic)
|
||||
if !isValid {
|
||||
return nil, ErrInvalidMnemonic
|
||||
}
|
||||
|
||||
// Some bitwise operands for working with big.Ints
|
||||
shift11BitsMask := big.NewInt(2048)
|
||||
bigOne := big.NewInt(1)
|
||||
|
||||
// used to isolate the checksum bits from the entropy+checksum byte array
|
||||
wordLengthChecksumMasksMapping := map[int]*big.Int{
|
||||
12: big.NewInt(15),
|
||||
15: big.NewInt(31),
|
||||
18: big.NewInt(63),
|
||||
21: big.NewInt(127),
|
||||
24: big.NewInt(255),
|
||||
}
|
||||
// used to use only the desired x of 8 available checksum bits.
|
||||
// 256 bit (word length 24) requires all 8 bits of the checksum,
|
||||
// and thus no shifting is needed for it (we would get a divByZero crash if we did)
|
||||
wordLengthChecksumShiftMapping := map[int]*big.Int{
|
||||
12: big.NewInt(16),
|
||||
15: big.NewInt(8),
|
||||
18: big.NewInt(4),
|
||||
21: big.NewInt(2),
|
||||
}
|
||||
|
||||
// wordMap is a reverse lookup map for the word list
|
||||
wordMap := map[string]int{}
|
||||
for i, v := range English() {
|
||||
wordMap[v] = i
|
||||
}
|
||||
|
||||
// Decode the words into a big.Int.
|
||||
b := big.NewInt(0)
|
||||
|
||||
for _, v := range mnemonicSlice {
|
||||
index, found := wordMap[v]
|
||||
if !found {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: word `%v` not found in reverse map", ErrInvalidMnemonic, v,
|
||||
)
|
||||
}
|
||||
|
||||
var wordBytes [2]byte
|
||||
|
||||
//nolint:gosec // the index of a word in the list is below 2048
|
||||
binary.BigEndian.PutUint16(wordBytes[:], uint16(index))
|
||||
|
||||
b = b.Mul(b, shift11BitsMask)
|
||||
b = b.Or(b, big.NewInt(0).SetBytes(wordBytes[:]))
|
||||
}
|
||||
|
||||
// Build and add the checksum to the big.Int.
|
||||
checksum := big.NewInt(0)
|
||||
checksumMask := wordLengthChecksumMasksMapping[len(mnemonicSlice)]
|
||||
checksum = checksum.And(b, checksumMask)
|
||||
|
||||
b.Div(b, big.NewInt(0).Add(checksumMask, bigOne))
|
||||
|
||||
// The entropy is the underlying bytes of the big.Int. Any upper bytes of
|
||||
// all 0's are not returned so we pad the beginning of the slice with empty
|
||||
// bytes if necessary.
|
||||
entropy := b.Bytes()
|
||||
entropy = padByteSlice(entropy, len(mnemonicSlice)/3*4)
|
||||
|
||||
// Generate the checksum and compare with the one we got from the mneomnic.
|
||||
entropyChecksumBytes := computeChecksum(entropy)
|
||||
entropyChecksum := big.NewInt(int64(entropyChecksumBytes[0]))
|
||||
|
||||
if l := len(mnemonicSlice); l != 24 {
|
||||
checksumShift := wordLengthChecksumShiftMapping[l]
|
||||
entropyChecksum.Div(entropyChecksum, checksumShift)
|
||||
}
|
||||
|
||||
if checksum.Cmp(entropyChecksum) != 0 {
|
||||
return nil, ErrChecksumIncorrect
|
||||
}
|
||||
|
||||
return entropy, nil
|
||||
}
|
||||
|
||||
// NewMnemonic will return a string consisting of the mnemonic words for
|
||||
// the given entropy.
|
||||
// If the provide entropy is invalid, an error will be returned.
|
||||
func NewMnemonic(entropy []byte) (string, error) {
|
||||
// Compute some lengths for convenience.
|
||||
entropyBitLength := len(entropy) * 8
|
||||
checksumBitLength := entropyBitLength / 32
|
||||
sentenceLength := (entropyBitLength + checksumBitLength) / 11
|
||||
|
||||
// Validate that the requested size is supported.
|
||||
err := validateEntropyBitSize(entropyBitLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Some bitwise operands for working with big.Ints
|
||||
last11BitsMask := big.NewInt(2047)
|
||||
shift11BitsMask := big.NewInt(2048)
|
||||
|
||||
// wordList is the set of words to use
|
||||
wordList := English()
|
||||
|
||||
// Add checksum to entropy.
|
||||
entropy = addChecksum(entropy)
|
||||
|
||||
// Break entropy up into sentenceLength chunks of 11 bits.
|
||||
// For each word AND mask the rightmost 11 bits and find the word at that index.
|
||||
// Then bitshift entropy 11 bits right and repeat.
|
||||
// Add to the last empty slot so we can work with LSBs instead of MSB.
|
||||
|
||||
// Entropy as an int so we can bitmask without worrying about bytes slices.
|
||||
entropyInt := new(big.Int).SetBytes(entropy)
|
||||
|
||||
// Slice to hold words in.
|
||||
words := make([]string, sentenceLength)
|
||||
|
||||
// Throw away big.Int for AND masking.
|
||||
word := big.NewInt(0)
|
||||
|
||||
for i := sentenceLength - 1; i >= 0; i-- {
|
||||
// Get 11 right most bits and bitshift 11 to the right for next time.
|
||||
word.And(entropyInt, last11BitsMask)
|
||||
entropyInt.Div(entropyInt, shift11BitsMask)
|
||||
|
||||
// Get the bytes representing the 11 bits as a 2 byte slice.
|
||||
wordBytes := padByteSlice(word.Bytes(), 2)
|
||||
|
||||
// Convert bytes to an index and add that word to the list.
|
||||
words[i] = wordList[binary.BigEndian.Uint16(wordBytes)]
|
||||
}
|
||||
|
||||
return strings.Join(words, " "), nil
|
||||
}
|
||||
|
||||
// NewSeed creates a hashed seed output given a provided string and password.
|
||||
// No checking is performed to validate that the string provided is a valid mnemonic.
|
||||
func NewSeed(mnemonic string, password string) []byte {
|
||||
return pbkdf2.Key([]byte(mnemonic), []byte("mnemonic"+password), 2048, 64, sha512.New)
|
||||
}
|
||||
|
||||
// IsMnemonicValid attempts to verify that the provided mnemonic is valid.
|
||||
// Validity is determined by both the number of words being appropriate,
|
||||
// and that all the words in the mnemonic are present in the word list.
|
||||
func IsMnemonicValid(mnemonic string) bool {
|
||||
_, err := EntropyFromMnemonic(mnemonic)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Appends to data the first (len(data) / 32)bits of the result of sha256(data)
|
||||
// Currently only supports data up to 32 bytes
|
||||
func addChecksum(data []byte) []byte {
|
||||
// Some bitwise operands for working with big.Ints
|
||||
bigOne := big.NewInt(1)
|
||||
bigTwo := big.NewInt(2)
|
||||
|
||||
// Get first byte of sha256
|
||||
hash := computeChecksum(data)
|
||||
firstChecksumByte := hash[0]
|
||||
|
||||
// len() is in bytes so we divide by 4
|
||||
checksumBitLength := uint(len(data) / 4)
|
||||
|
||||
// For each bit of check sum we want we shift the data one the left
|
||||
// and then set the (new) right most bit equal to checksum bit at that index
|
||||
// staring from the left
|
||||
dataBigInt := new(big.Int).SetBytes(data)
|
||||
for i := range checksumBitLength {
|
||||
// Bitshift 1 left
|
||||
dataBigInt.Mul(dataBigInt, bigTwo)
|
||||
|
||||
// Set rightmost bit if leftmost checksum bit is set
|
||||
if firstChecksumByte&(1<<(7-i)) > 0 {
|
||||
dataBigInt.Or(dataBigInt, bigOne)
|
||||
}
|
||||
}
|
||||
|
||||
return dataBigInt.Bytes()
|
||||
}
|
||||
|
||||
func computeChecksum(data []byte) []byte {
|
||||
hasher := sha256.New()
|
||||
hasher.Write(data)
|
||||
|
||||
return hasher.Sum(nil)
|
||||
}
|
||||
|
||||
// validateEntropyBitSize ensures that entropy is the correct size for being a
|
||||
// mnemonic.
|
||||
func validateEntropyBitSize(bitSize int) error {
|
||||
if (bitSize%32) != 0 || bitSize < 128 || bitSize > 256 {
|
||||
return ErrEntropyLengthInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// padByteSlice returns a byte slice of the given size with contents of the
|
||||
// given slice left padded and any empty spaces filled with 0's.
|
||||
func padByteSlice(slice []byte, length int) []byte {
|
||||
offset := length - len(slice)
|
||||
if offset <= 0 {
|
||||
return slice
|
||||
}
|
||||
|
||||
newSlice := make([]byte, length)
|
||||
copy(newSlice[offset:], slice)
|
||||
|
||||
return newSlice
|
||||
}
|
||||
|
||||
func splitMnemonicWords(mnemonic string) ([]string, bool) {
|
||||
// Create a list of all the words in the mnemonic sentence
|
||||
words := strings.Fields(mnemonic)
|
||||
|
||||
// Get num of words
|
||||
numOfWords := len(words)
|
||||
|
||||
// The number of words should be 12, 15, 18, 21 or 24
|
||||
if numOfWords%3 != 0 || numOfWords < 12 || numOfWords > 24 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return words, true
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type vector struct {
|
||||
entropy string
|
||||
mnemonic string
|
||||
seed string
|
||||
}
|
||||
|
||||
func TestNewMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, vector := range testVectors() {
|
||||
entropy, err := hex.DecodeString(vector.entropy)
|
||||
assertNil(t, err)
|
||||
|
||||
mnemonic, err := NewMnemonic(entropy)
|
||||
assertNil(t, err)
|
||||
assertEqualString(t, vector.mnemonic, mnemonic)
|
||||
|
||||
seed := NewSeed(mnemonic, "TREZOR")
|
||||
assertEqualString(t, vector.seed, hex.EncodeToString(seed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewMnemonicInvalidEntropy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := NewMnemonic([]byte{})
|
||||
assertNotNil(t, err)
|
||||
}
|
||||
|
||||
func TestIsMnemonicValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, vector := range badMnemonicSentences() {
|
||||
assertFalse(t, IsMnemonicValid(vector.mnemonic))
|
||||
}
|
||||
|
||||
for _, vector := range testVectors() {
|
||||
assertTrue(t, IsMnemonicValid(vector.mnemonic))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadByteSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertEqualByteSlices(t, []byte{0}, padByteSlice([]byte{}, 1))
|
||||
assertEqualByteSlices(t, []byte{0, 1}, padByteSlice([]byte{1}, 2))
|
||||
assertEqualByteSlices(t, []byte{1, 1}, padByteSlice([]byte{1, 1}, 2))
|
||||
assertEqualByteSlices(t, []byte{1, 1, 1}, padByteSlice([]byte{1, 1, 1}, 2))
|
||||
}
|
||||
|
||||
//nolint:funlen // the test vectors, kept as upstream wrote them
|
||||
func TestMnemonicToByteArrayForZeroLeadingSeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ms := []string{
|
||||
"00000000000000000000000000000000",
|
||||
"00a84c51041d49acca66e6160c1fa999",
|
||||
"00ca45df1673c76537a2020bfed1dafd",
|
||||
"0019d5871c7b81fd83d474ef1c1e1dae",
|
||||
"00dcb021afb35ffcdd1d032d2056fc86",
|
||||
"0062be7bd09a27288b6cf0eb565ec739",
|
||||
"00dc705b5efa0adf25b9734226ba60d4",
|
||||
"0017747418d54c6003fa64fade83374b",
|
||||
"000d44d3ee7c3dfa45e608c65384431b",
|
||||
"008241c1ef976b0323061affe5bf24b9",
|
||||
"00a6aec77e4d16bea80b50a34991aaba",
|
||||
"0011527b8c6ddecb9d0c20beccdeb58d",
|
||||
"001c938c503c8f5a2bba2248ff621546",
|
||||
"0002f90aaf7a8327698f0031b6317c36",
|
||||
"00bff43071ed7e07f77b14f615993bac",
|
||||
"00da143e00ef17fc63b6fb22dcc2c326",
|
||||
"00ffc6764fb32a354cab1a3ddefb015d",
|
||||
"0062ef47e0985e8953f24760b7598cdd",
|
||||
"003bf9765064f71d304908d906c065f5",
|
||||
"00993851503471439d154b3613947474",
|
||||
"007ad0ffe9eae753a483a76af06dfa67",
|
||||
"00091824db9ec19e663bee51d64c83cc",
|
||||
"00f48ac621f7e3cb39b2012ac3121543",
|
||||
"0072917415cdca24dfa66c4a92c885b4",
|
||||
"0027ced2b279ea8a91d29364487cdbf4",
|
||||
"00b9c0d37fb10ba272e55842ad812583",
|
||||
"004b3d0d2b9285946c687a5350479c8c",
|
||||
"00c7c12a37d3a7f8c1532b17c89b724c",
|
||||
"00f400c5545f06ae17ad00f3041e4e26",
|
||||
"001e290be10df4d209f247ac5878662b",
|
||||
"00bf0f74568e582a7dd1ee64f792ec8b",
|
||||
"00d2e43ecde6b72b847db1539ed89e23",
|
||||
"00cecba6678505bb7bfec8ed307251f6",
|
||||
"000aeed1a9edcbb4bc88f610d3ce84eb",
|
||||
"00d06206aadfc25c2b21805d283f15ae",
|
||||
"00a31789a2ab2d54f8fadd5331010287",
|
||||
"003493c5f520e8d5c0483e895a121dc9",
|
||||
"004706112800b76001ece2e268bc830e",
|
||||
"00ab31e28bb5305be56e38337dbfa486",
|
||||
"006872fe85df6b0fa945248e6f9379d1",
|
||||
"00717e5e375da6934e3cfdf57edaf3bd",
|
||||
"007f1b46e7b9c4c76e77c434b9bccd6b",
|
||||
"00dc93735aa35def3b9a2ff676560205",
|
||||
"002cd5dcd881a49c7b87714c6a570a76",
|
||||
"0013b5af9e13fac87e0c505686cfb6bf",
|
||||
"007ab1ec9526b0bc04b64ae65fd42631",
|
||||
"00abb4e11d8385c1cca905a6a65e9144",
|
||||
"00574fc62a0501ad8afada2e246708c3",
|
||||
"005207e0a815bb2da6b4c35ec1f2bf52",
|
||||
"00f3460f136fb9700080099cbd62bc18",
|
||||
"007a591f204c03ca7b93981237112526",
|
||||
"00cfe0befd428f8e5f83a5bfc801472e",
|
||||
"00987551ac7a879bf0c09b8bc474d9af",
|
||||
"00cadd3ce3d78e49fbc933a85682df3f",
|
||||
"00bfbf2e346c855ccc360d03281455a1",
|
||||
"004cdf55d429d028f715544ce22d4f31",
|
||||
"0075c84a7d15e0ac85e1e41025eed23b",
|
||||
"00807dddd61f71725d336cab844d2cb5",
|
||||
"00422f21b77fe20e367467ed98c18410",
|
||||
"00b44d0ac622907119c626c850a462fd",
|
||||
"00363f5e7f22fc49f3cd662a28956563",
|
||||
"000fe5837e68397bbf58db9f221bdc4e",
|
||||
"0056af33835c888ef0c22599686445d3",
|
||||
"00790a8647fd3dfb38b7e2b6f578f2c6",
|
||||
"00da8d9009675cb7beec930e263014fb",
|
||||
"00d4b384540a5bb54aa760edaa4fb2fe",
|
||||
"00be9b1479ed680fdd5d91a41eb926d0",
|
||||
"009182347502af97077c40a6e74b4b5c",
|
||||
"00f5c90ee1c67fa77fd821f8e9fab4f1",
|
||||
"005568f9a2dd6b0c0cc2f5ba3d9cac38",
|
||||
"008b481f8678577d9cf6aa3f6cd6056b",
|
||||
"00c4323ece5e4fe3b6cd4c5c932931af",
|
||||
"009791f7550c3798c5a214cb2d0ea773",
|
||||
"008a7baab22481f0ad8167dd9f90d55c",
|
||||
"00f0e601519aafdc8ff94975e64c946d",
|
||||
"0083b61e0daa9219df59d697c270cd31",
|
||||
}
|
||||
|
||||
for _, m := range ms {
|
||||
seed, _ := hex.DecodeString(m)
|
||||
|
||||
mnemonic, err := NewMnemonic(seed)
|
||||
if err != nil {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
|
||||
_, err = EntropyFromMnemonic(mnemonic)
|
||||
if err != nil {
|
||||
t.Errorf("Failed for %x - %v", seed, mnemonic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic128(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 128)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic160(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 160)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic192(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 192)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic224(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 224)
|
||||
}
|
||||
|
||||
func TestEntropyFromMnemonic256(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testEntropyFromMnemonic(t, 256)
|
||||
}
|
||||
|
||||
//nolint:dupword,lll // the test vector, kept as upstream wrote it
|
||||
func TestEntropyFromMnemonicInvalidChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := EntropyFromMnemonic("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon yellow")
|
||||
assertEqual(t, ErrChecksumIncorrect, err)
|
||||
}
|
||||
|
||||
//nolint:dupword // the test vectors, kept as upstream wrote them
|
||||
func TestEntropyFromMnemonicInvalidMnemonicSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, mnemonic := range []string{
|
||||
"a a a a a a a a a a a a a a a a a a a a a a a a a", // Too many words
|
||||
"a", // Too few
|
||||
"a a a a a a a a a a a a a a", // Not multiple of 3
|
||||
} {
|
||||
_, err := EntropyFromMnemonic(mnemonic)
|
||||
assertEqual(t, ErrInvalidMnemonic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testEntropyFromMnemonic(t *testing.T, bitSize int) {
|
||||
t.Helper()
|
||||
|
||||
for range 512 {
|
||||
expectedEntropy := make([]byte, bitSize/8)
|
||||
_, err := rand.Read(expectedEntropy)
|
||||
assertNil(t, err)
|
||||
assertTrue(t, len(expectedEntropy) != 0)
|
||||
|
||||
mnemonic, err := NewMnemonic(expectedEntropy)
|
||||
assertNil(t, err)
|
||||
assertTrue(t, len(mnemonic) != 0)
|
||||
|
||||
actualEntropy, err := EntropyFromMnemonic(mnemonic)
|
||||
assertNil(t, err)
|
||||
assertEqualByteSlices(t, expectedEntropy, actualEntropy)
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:dupword,funlen,lll // the BIP-39 test vectors, kept as upstream wrote them
|
||||
func testVectors() []vector {
|
||||
return []vector{
|
||||
{
|
||||
entropy: "00000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
||||
seed: "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||
seed: "2e8905819b8723fe2c1d161860e5ee1830318dbf49a83bd451cfb8440c28bd6fa457fe1296106559a3c80937a1c1069be3a3a5bd381ee6260e8d9739fce1f607",
|
||||
},
|
||||
{
|
||||
entropy: "80808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
|
||||
seed: "d71de856f81a8acc65e6fc851a38d4d7ec216fd0796d0a6827a3ad6ed5511a30fa280f12eb2e47ed2ac03b5c462a0358d18d69fe4f985ec81778c1b370b652a8",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||
seed: "ac27495480225222079d7be181583751e86f571027b0497b5b5d11218e0a8a13332572917f0f8e5a589620c6f15b11c61dee327651a14c34e18231052e48c069",
|
||||
},
|
||||
{
|
||||
entropy: "000000000000000000000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent",
|
||||
seed: "035895f2f481b1b0f01fcf8c289c794660b289981a78f8106447707fdd9666ca06da5a9a565181599b79f53b844d8a71dd9f439c52a3d7b3e8a79c906ac845fa",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will",
|
||||
seed: "f2b94508732bcbacbcc020faefecfc89feafa6649a5491b8c952cede496c214a0c7b3c392d168748f2d4a612bada0753b52a1c7ac53c1e93abd5c6320b9e95dd",
|
||||
},
|
||||
{
|
||||
entropy: "808080808080808080808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always",
|
||||
seed: "107d7c02a5aa6f38c58083ff74f04c607c2d2c0ecc55501dadd72d025b751bc27fe913ffb796f841c49b1d33b610cf0e91d3aa239027f5e99fe4ce9e5088cd65",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo when",
|
||||
seed: "0cd6e5d827bb62eb8fc1e262254223817fd068a74b5b449cc2f667c3f1f985a76379b43348d952e2265b4cd129090758b3e3c2c49103b5051aac2eaeb890a528",
|
||||
},
|
||||
{
|
||||
entropy: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art",
|
||||
seed: "bda85446c68413707090a52022edd26a1c9462295029f2e60cd7c4f2bbd3097170af7a4d73245cafa9c3cca8d561a7c3de6f5d4a10be8ed2a5e608d68f92fcc8",
|
||||
},
|
||||
{
|
||||
entropy: "7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f",
|
||||
mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth title",
|
||||
seed: "bc09fca1804f7e69da93c2f2028eb238c227f2e9dda30cd63699232578480a4021b146ad717fbb7e451ce9eb835f43620bf5c514db0f8add49f5d121449d3e87",
|
||||
},
|
||||
{
|
||||
entropy: "8080808080808080808080808080808080808080808080808080808080808080",
|
||||
mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless",
|
||||
seed: "c0c519bd0e91a2ed54357d9d1ebef6f5af218a153624cf4f2da911a0ed8f7a09e2ef61af0aca007096df430022f7a2b6fb91661a9589097069720d015e4e982f",
|
||||
},
|
||||
{
|
||||
entropy: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo vote",
|
||||
seed: "dd48c104698c30cfe2b6142103248622fb7bb0ff692eebb00089b32d22484e1613912f0a5b694407be899ffd31ed3992c456cdf60f5d4564b8ba3f05a69890ad",
|
||||
},
|
||||
{
|
||||
entropy: "77c2b00716cec7213839159e404db50d",
|
||||
mnemonic: "jelly better achieve collect unaware mountain thought cargo oxygen act hood bridge",
|
||||
seed: "b5b6d0127db1a9d2226af0c3346031d77af31e918dba64287a1b44b8ebf63cdd52676f672a290aae502472cf2d602c051f3e6f18055e84e4c43897fc4e51a6ff",
|
||||
},
|
||||
{
|
||||
entropy: "b63a9c59a6e641f288ebc103017f1da9f8290b3da6bdef7b",
|
||||
mnemonic: "renew stay biology evidence goat welcome casual join adapt armor shuffle fault little machine walk stumble urge swap",
|
||||
seed: "9248d83e06f4cd98debf5b6f010542760df925ce46cf38a1bdb4e4de7d21f5c39366941c69e1bdbf2966e0f6e6dbece898a0e2f0a4c2b3e640953dfe8b7bbdc5",
|
||||
},
|
||||
{
|
||||
entropy: "3e141609b97933b66a060dcddc71fad1d91677db872031e85f4c015c5e7e8982",
|
||||
mnemonic: "dignity pass list indicate nasty swamp pool script soccer toe leaf photo multiply desk host tomato cradle drill spread actor shine dismiss champion exotic",
|
||||
seed: "ff7f3184df8696d8bef94b6c03114dbee0ef89ff938712301d27ed8336ca89ef9635da20af07d4175f2bf5f3de130f39c9d9e8dd0472489c19b1a020a940da67",
|
||||
},
|
||||
{
|
||||
entropy: "0460ef47585604c5660618db2e6a7e7f",
|
||||
mnemonic: "afford alter spike radar gate glance object seek swamp infant panel yellow",
|
||||
seed: "65f93a9f36b6c85cbe634ffc1f99f2b82cbb10b31edc7f087b4f6cb9e976e9faf76ff41f8f27c99afdf38f7a303ba1136ee48a4c1e7fcd3dba7aa876113a36e4",
|
||||
},
|
||||
{
|
||||
entropy: "72f60ebac5dd8add8d2a25a797102c3ce21bc029c200076f",
|
||||
mnemonic: "indicate race push merry suffer human cruise dwarf pole review arch keep canvas theme poem divorce alter left",
|
||||
seed: "3bbf9daa0dfad8229786ace5ddb4e00fa98a044ae4c4975ffd5e094dba9e0bb289349dbe2091761f30f382d4e35c4a670ee8ab50758d2c55881be69e327117ba",
|
||||
},
|
||||
{
|
||||
entropy: "2c85efc7f24ee4573d2b81a6ec66cee209b2dcbd09d8eddc51e0215b0b68e416",
|
||||
mnemonic: "clutch control vehicle tonight unusual clog visa ice plunge glimpse recipe series open hour vintage deposit universe tip job dress radar refuse motion taste",
|
||||
seed: "fe908f96f46668b2d5b37d82f558c77ed0d69dd0e7e043a5b0511c48c2f1064694a956f86360c93dd04052a8899497ce9e985ebe0c8c52b955e6ae86d4ff4449",
|
||||
},
|
||||
{
|
||||
entropy: "eaebabb2383351fd31d703840b32e9e2",
|
||||
mnemonic: "turtle front uncle idea crush write shrug there lottery flower risk shell",
|
||||
seed: "bdfb76a0759f301b0b899a1e3985227e53b3f51e67e3f2a65363caedf3e32fde42a66c404f18d7b05818c95ef3ca1e5146646856c461c073169467511680876c",
|
||||
},
|
||||
{
|
||||
entropy: "7ac45cfe7722ee6c7ba84fbc2d5bd61b45cb2fe5eb65aa78",
|
||||
mnemonic: "kiss carry display unusual confirm curtain upgrade antique rotate hello void custom frequent obey nut hole price segment",
|
||||
seed: "ed56ff6c833c07982eb7119a8f48fd363c4a9b1601cd2de736b01045c5eb8ab4f57b079403485d1c4924f0790dc10a971763337cb9f9c62226f64fff26397c79",
|
||||
},
|
||||
{
|
||||
entropy: "4fa1a8bc3e6d80ee1316050e862c1812031493212b7ec3f3bb1b08f168cabeef",
|
||||
mnemonic: "exile ask congress lamp submit jacket era scheme attend cousin alcohol catch course end lucky hurt sentence oven short ball bird grab wing top",
|
||||
seed: "095ee6f817b4c2cb30a5a797360a81a40ab0f9a4e25ecd672a3f58a0b5ba0687c096a6b14d2c0deb3bdefce4f61d01ae07417d502429352e27695163f7447a8c",
|
||||
},
|
||||
{
|
||||
entropy: "18ab19a9f54a9274f03e5209a2ac8a91",
|
||||
mnemonic: "board flee heavy tunnel powder denial science ski answer betray cargo cat",
|
||||
seed: "6eff1bb21562918509c73cb990260db07c0ce34ff0e3cc4a8cb3276129fbcb300bddfe005831350efd633909f476c45c88253276d9fd0df6ef48609e8bb7dca8",
|
||||
},
|
||||
{
|
||||
entropy: "18a2e1d81b8ecfb2a333adcb0c17a5b9eb76cc5d05db91a4",
|
||||
mnemonic: "board blade invite damage undo sun mimic interest slam gaze truly inherit resist great inject rocket museum chief",
|
||||
seed: "f84521c777a13b61564234bf8f8b62b3afce27fc4062b51bb5e62bdfecb23864ee6ecf07c1d5a97c0834307c5c852d8ceb88e7c97923c0a3b496bedd4e5f88a9",
|
||||
},
|
||||
{
|
||||
entropy: "15da872c95a13dd738fbf50e427583ad61f18fd99f628c417a61cf8343c90419",
|
||||
mnemonic: "beyond stage sleep clip because twist token leaf atom beauty genius food business side grid unable middle armed observe pair crouch tonight away coconut",
|
||||
seed: "b15509eaa2d09d3efd3e006ef42151b30367dc6e3aa5e44caba3fe4d3e352e65101fbdb86a96776b91946ff06f8eac594dc6ee1d3e82a42dfe1b40fef6bcc3fd",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:dupword,lll // the test vectors, kept as upstream wrote them
|
||||
func badMnemonicSentences() []vector {
|
||||
return []vector{
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank yellow yellow"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice caged above"},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo, wrong"},
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thank year wave sausage worth useful legal will will will"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic avoid letter always."},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo why"},
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art art"},
|
||||
{mnemonic: "legal winner thank year wave sausage worth useful legal winner thanks year wave worth useful legal winner thank year wave sausage worth title"},
|
||||
{mnemonic: "letter advice cage absurd amount doctor acoustic avoid letters advice cage absurd amount doctor acoustic avoid letter advice cage absurd amount doctor acoustic bless"},
|
||||
{mnemonic: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo voted"},
|
||||
{mnemonic: "jello better achieve collect unaware mountain thought cargo oxygen act hood bridge"},
|
||||
{mnemonic: "renew, stay, biology, evidence, goat, welcome, casual, join, adapt, armor, shuffle, fault, little, machine, walk, stumble, urge, swap"},
|
||||
{mnemonic: "dignity pass list indicate nasty"},
|
||||
|
||||
// From issue 32
|
||||
{mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon letter"},
|
||||
}
|
||||
}
|
||||
|
||||
func assertNil(t *testing.T, object any) {
|
||||
t.Helper()
|
||||
|
||||
if object != nil {
|
||||
t.Errorf("Expected nil, got %v", object)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNotNil(t *testing.T, object any) {
|
||||
t.Helper()
|
||||
|
||||
if object == nil {
|
||||
t.Error("Expected not nil")
|
||||
}
|
||||
}
|
||||
|
||||
func assertTrue(t *testing.T, a bool) {
|
||||
t.Helper()
|
||||
|
||||
if !a {
|
||||
t.Error("Expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func assertFalse(t *testing.T, a bool) {
|
||||
t.Helper()
|
||||
|
||||
if a {
|
||||
t.Error("Expected false, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqual(t *testing.T, a, b any) {
|
||||
t.Helper()
|
||||
|
||||
if a != b {
|
||||
t.Errorf("Objects not equal, expected `%s` and got `%s`", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqualString(t *testing.T, a, b string) {
|
||||
t.Helper()
|
||||
|
||||
if a != b {
|
||||
t.Errorf("Strings not equal, expected `%s` and got `%s`", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEqualByteSlices(t *testing.T, a, b []byte) {
|
||||
t.Helper()
|
||||
|
||||
if len(a) != len(b) {
|
||||
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Errorf("Byte slices not equal, expected %v and got %v", a, b)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
package bip39
|
||||
|
||||
import (
|
||||
"hash/crc32"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnglishChecksum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Ensure word list is correct
|
||||
// $ wget https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt
|
||||
// $ crc32 english.txt
|
||||
// c1dbd296
|
||||
checksum := crc32.ChecksumIEEE([]byte(english))
|
||||
if checksum != 0xc1dbd296 {
|
||||
t.Error("english checksum invalid")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package bip39_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
)
|
||||
|
||||
//nolint:lll // the test vector and its output, kept as upstream wrote them
|
||||
func ExampleNewMnemonic() {
|
||||
// the entropy can be any byte slice, generated how pleased,
|
||||
// as long its bit size is a multiple of 32 and is within
|
||||
// the inclusive range of {128,256}
|
||||
entropy, _ := hex.DecodeString("066dca1a2bb7e8a1db2832148ce9933eea0f3ac9548d793112d9a95c9407efad")
|
||||
|
||||
// generate a mnemomic
|
||||
mnemomic, _ := bip39.NewMnemonic(entropy)
|
||||
fmt.Println(mnemomic)
|
||||
// output:
|
||||
// all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform
|
||||
}
|
||||
|
||||
//nolint:lll // the test vector and its output, kept as upstream wrote them
|
||||
func ExampleNewSeed() {
|
||||
seed := bip39.NewSeed("all hour make first leader extend hole alien behind guard gospel lava path output census museum junior mass reopen famous sing advance salt reform", "TREZOR")
|
||||
fmt.Println(hex.EncodeToString(seed))
|
||||
// output:
|
||||
// 26e975ec644423f4a4c4f4215ef09b4bd7ef924e85d1d17c4cf3f136c2863cf6df0a475045652c57eb5fb41513ca2a2d67722b77e954b4b3fc11f7590449191d
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/secret/pkg/bip85"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/derive"
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/stretchr/testify/require"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/childmnemonic"
|
||||
"sneak.berlin/go/keyfunc/internal/derive"
|
||||
)
|
||||
|
||||
+156
-22
@@ -3,17 +3,28 @@
|
||||
package age
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/options"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||
"sneak.berlin/go/keyfunc/internal/derive"
|
||||
)
|
||||
|
||||
// ErrInterrupted is returned when SIGINT, SIGTERM or SIGHUP has been
|
||||
// received by the time the work writing the file --output names ends.
|
||||
var ErrInterrupted = errors.New(
|
||||
"interrupted by a signal; the output file was left as it was",
|
||||
)
|
||||
|
||||
// Command returns the age command and everything under it.
|
||||
func Command() *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
@@ -128,8 +139,12 @@ func runDecrypt(cmd *cobra.Command, args []string) error {
|
||||
return through(cmd, args, key.Decrypt)
|
||||
}
|
||||
|
||||
// through opens the input and the output the arguments ask for, hands
|
||||
// them to the work, and finishes the output afterwards either way.
|
||||
// through opens the input the arguments ask for and hands it to the
|
||||
// work, with the file --output names to write to, or the command's own
|
||||
// output when it names none or names the same file as that output, and
|
||||
// the command's own error output when it names the same file as that.
|
||||
// Those two are the streams the tool already has, so whatever they are
|
||||
// redirected to is written as the redirect says, never replaced.
|
||||
func through(
|
||||
cmd *cobra.Command, args []string,
|
||||
work func(io.Writer, io.Reader) error,
|
||||
@@ -141,14 +156,41 @@ func through(
|
||||
|
||||
defer closeSrc()
|
||||
|
||||
dst, done, err := output(cmd)
|
||||
name, err := cmd.Flags().GetString("output")
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("reading the output file: %w", err)
|
||||
}
|
||||
|
||||
err = work(dst, src)
|
||||
switch {
|
||||
case name == "", same(name, cmd.OutOrStdout()):
|
||||
return work(cmd.OutOrStdout(), src)
|
||||
case same(name, cmd.ErrOrStderr()):
|
||||
return work(cmd.ErrOrStderr(), src)
|
||||
default:
|
||||
return output(name, src, work)
|
||||
}
|
||||
}
|
||||
|
||||
return done(err)
|
||||
// same reports whether the named path, followed to the end, is the
|
||||
// file the stream writes to, whatever name it is reached by, such as
|
||||
// /dev/stdout or /dev/fd/1 for standard output.
|
||||
func same(name string, stream io.Writer) bool {
|
||||
file, ok := stream.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
streamInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := os.Stat(name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return os.SameFile(info, streamInfo)
|
||||
}
|
||||
|
||||
// input returns what to read from: the named file, or the command's
|
||||
@@ -167,35 +209,127 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
|
||||
return file, func() { _ = file.Close() }, nil
|
||||
}
|
||||
|
||||
// output returns what to write to: a new file beside the one --output
|
||||
// names, or the command's own output when it names none. The second
|
||||
// result finishes the write, and is given whatever the work returned:
|
||||
// the new file takes the named file's place only when the work
|
||||
// succeeded, so a file that is already there survives a run that
|
||||
// failed.
|
||||
func output(cmd *cobra.Command) (io.Writer, func(error) error, error) {
|
||||
name, err := cmd.Flags().GetString("output")
|
||||
// output has the work write to the named path, going by what is there
|
||||
// without following a final symlink:
|
||||
//
|
||||
// - nothing, or a regular file: replace writes a new file beside it
|
||||
// and renames that over it;
|
||||
// - a symlink: the same for what it points at, so that the link keeps
|
||||
// pointing where it did; one that points at nothing is refused;
|
||||
// - anything else, such as a named pipe or a device like /dev/null:
|
||||
// direct writes to it, since a rename would put a regular file in
|
||||
// its place.
|
||||
func output(
|
||||
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
||||
) error {
|
||||
info, err := os.Lstat(name)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
return replace(name, src, work)
|
||||
case err != nil:
|
||||
return fmt.Errorf("looking at %s: %w", name, err)
|
||||
case info.Mode().IsRegular():
|
||||
return replace(name, src, work)
|
||||
case info.Mode().Type() == fs.ModeSymlink:
|
||||
// os.Stat follows the link as opening it would. /dev/fd/3
|
||||
// needs that: it reaches a pipe or a terminal through a link
|
||||
// that names no path.
|
||||
info, err = os.Stat(name)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("reading the output file: %w", err)
|
||||
return fmt.Errorf("following %s: %w", name, err)
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
return cmd.OutOrStdout(), func(failed error) error {
|
||||
return failed
|
||||
}, nil
|
||||
if !info.Mode().IsRegular() {
|
||||
return direct(name, src, work)
|
||||
}
|
||||
|
||||
target, err := filepath.EvalSymlinks(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("following %s: %w", name, err)
|
||||
}
|
||||
|
||||
return replace(target, src, work)
|
||||
default:
|
||||
return direct(name, src, work)
|
||||
}
|
||||
}
|
||||
|
||||
// direct has the work write straight to the named path, which is there
|
||||
// and is not a regular file. No signal is caught, so one ends the tool
|
||||
// as it ends any other command.
|
||||
func direct(
|
||||
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
||||
) error {
|
||||
file, err := os.OpenFile(name, os.O_WRONLY, 0) //nolint:gosec // the -o path
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening %s: %w", name, err)
|
||||
}
|
||||
|
||||
failed := work(file, src)
|
||||
closeErr := file.Close()
|
||||
|
||||
if failed != nil {
|
||||
return failed
|
||||
}
|
||||
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("finishing %s: %w", name, closeErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// replace has the work write a new file beside the named one, and puts
|
||||
// the new file in the named file's place only when the work succeeded,
|
||||
// so a file that is already there survives a run that failed.
|
||||
//
|
||||
// Meanwhile SIGINT, SIGTERM and SIGHUP are caught, as signals.Context
|
||||
// does. One the tool has received by the time the work ends wins: the
|
||||
// new file is removed and ErrInterrupted returned, at once if the work
|
||||
// is still running, without waiting for it, since it may be blocked
|
||||
// reading its input.
|
||||
func replace(
|
||||
name string, src io.Reader, work func(io.Writer, io.Reader) error,
|
||||
) error {
|
||||
// received is registered before the context, so it gets every
|
||||
// signal the context gets.
|
||||
received := make(chan os.Signal, 1)
|
||||
signals.Notify(received)
|
||||
|
||||
defer signal.Stop(received)
|
||||
|
||||
// The context goes on catching the signals until the file is in
|
||||
// place or removed, so that a later one cannot end the tool with
|
||||
// the new file left beside the named one.
|
||||
interrupted, stop := signals.Context(context.Background())
|
||||
defer stop()
|
||||
|
||||
// The file is made in the same directory so that putting it in
|
||||
// place is a rename and never a copy, and it is readable only by
|
||||
// its owner, which is the mode it keeps once renamed.
|
||||
file, err := os.CreateTemp(filepath.Dir(name), filepath.Base(name)+".")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating a file beside %s: %w", name, err)
|
||||
return fmt.Errorf("creating a file beside %s: %w", name, err)
|
||||
}
|
||||
|
||||
return file, func(failed error) error {
|
||||
worked := make(chan error, 1)
|
||||
|
||||
go func() { worked <- work(file, src) }()
|
||||
|
||||
select {
|
||||
case failed := <-worked:
|
||||
// Stop returns only once every signal the tool has received
|
||||
// has been handed over, so an empty received means none came.
|
||||
signal.Stop(received)
|
||||
|
||||
if len(received) == 0 {
|
||||
return finish(file, name, failed)
|
||||
}, nil
|
||||
}
|
||||
case <-interrupted.Done():
|
||||
}
|
||||
|
||||
return finish(file, name, ErrInterrupted)
|
||||
}
|
||||
|
||||
// finish closes the new file and puts it in the named file's place, or
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/keyfunc/internal/agekey"
|
||||
"sneak.berlin/go/keyfunc/internal/cli"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/age"
|
||||
"sneak.berlin/go/keyfunc/internal/mnemonic"
|
||||
)
|
||||
|
||||
@@ -90,6 +99,360 @@ func TestARefusedDecryptionLeavesTheOutputFileAlone(t *testing.T) {
|
||||
require.Equal(t, "what was already there\n", string(kept))
|
||||
}
|
||||
|
||||
func TestASymlinkAtTheOutputPathStaysAndItsTargetGetsTheOutput(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
plain := written(t, "notes.txt", "the secret\n")
|
||||
target := written(t, "notes.age", "what was already there\n")
|
||||
link := filepath.Join(t.TempDir(), "notes.age")
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
run(t, "age", "encrypt", "-o", link, plain)
|
||||
|
||||
pointsAt, err := os.Readlink(link)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, target, pointsAt)
|
||||
require.Equal(t, "the secret\n", run(t, "age", "decrypt", target))
|
||||
}
|
||||
|
||||
func TestANamedPipeAtTheOutputPathIsWrittenToAndStaysAPipe(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
plain := written(t, "notes.txt", "the secret\n")
|
||||
pipe := filepath.Join(t.TempDir(), "notes.age")
|
||||
require.NoError(t, syscall.Mkfifo(pipe, fileMode))
|
||||
|
||||
// Opening the pipe to read waits until the tool opens it to write.
|
||||
var sealed []byte
|
||||
|
||||
finished := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
var err error
|
||||
|
||||
sealed, err = os.ReadFile(pipe) //nolint:gosec // the test's own path
|
||||
finished <- err
|
||||
}()
|
||||
|
||||
run(t, "age", "encrypt", "-o", pipe, plain)
|
||||
|
||||
select {
|
||||
case err := <-finished:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("nothing was written to the pipe")
|
||||
}
|
||||
|
||||
info, err := os.Lstat(pipe)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fs.ModeNamedPipe, info.Mode().Type())
|
||||
|
||||
sealedFile := written(t, "notes.age", string(sealed))
|
||||
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealedFile))
|
||||
}
|
||||
|
||||
func TestANameForStandardOutputAddsToTheFileItIsAppendedTo(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
sealed := filepath.Join(t.TempDir(), "notes.age")
|
||||
run(t, "age", "encrypt", "-o", sealed, written(t, "notes.txt", "the secret\n"))
|
||||
|
||||
for _, name := range []string{"/dev/stdout", "/dev/fd/1"} {
|
||||
appendedThrough(t, name, sealed)
|
||||
}
|
||||
}
|
||||
|
||||
// appendedThrough decrypts sealed with -o name while the tool's standard
|
||||
// output is appended to a file that already has contents, as the shell's
|
||||
// ">> notes.out" does, and checks that the file is the same one, with
|
||||
// the same mode, and holds its earlier contents and then the output.
|
||||
func appendedThrough(t *testing.T, name, sealed string) {
|
||||
t.Helper()
|
||||
|
||||
// A mode of its own, so that a replaced file would show.
|
||||
const ownMode = 0o644
|
||||
|
||||
existing := written(t, "notes.out", "what was already there\n")
|
||||
require.NoError(t, os.Chmod(existing, ownMode))
|
||||
|
||||
before, err := os.Stat(existing)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // the test made this path itself
|
||||
appended, err := os.OpenFile(existing, os.O_WRONLY|os.O_APPEND, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = appended.Close() }()
|
||||
|
||||
//nolint:gosec // this test's own binary as the tool
|
||||
command := exec.CommandContext(
|
||||
t.Context(), os.Args[0], "age", "decrypt", "-o", name, sealed,
|
||||
)
|
||||
|
||||
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||
command.Stdout = appended
|
||||
|
||||
require.NoError(t, command.Run(), name)
|
||||
require.Equal(t,
|
||||
"what was already there\nthe secret\n", read(t, existing), name,
|
||||
)
|
||||
|
||||
after, err := os.Stat(existing)
|
||||
require.NoError(t, err)
|
||||
require.True(t, os.SameFile(before, after), name)
|
||||
require.Equal(t, os.FileMode(ownMode), after.Mode().Perm(), name)
|
||||
}
|
||||
|
||||
func TestASignalStopsAnEncryptionAndLeavesNoFile(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
for _, ending := range []os.Signal{
|
||||
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||
} {
|
||||
interrupted(t, ending, "encrypt", "the start of the secret\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASignalStopsADecryptionAndLeavesNoFile(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
// All of an encryption but its last byte, so the tool reads the
|
||||
// header and then waits for the rest.
|
||||
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
||||
cut := sealed[:len(sealed)-1]
|
||||
|
||||
for _, ending := range []os.Signal{
|
||||
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||
} {
|
||||
interrupted(t, ending, "decrypt", cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASignalReceivedAsTheInputEndsLeavesTheFileAsItWas(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
||||
|
||||
for _, ending := range []syscall.Signal{
|
||||
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||
} {
|
||||
receivedAtTheEnd(t, ending, "encrypt", "the secret\n")
|
||||
receivedAtTheEnd(t, ending, "decrypt", sealed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASignalAsTheInputEndsLeavesNoUnfinishedFile(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
sealed := run(t, "age", "encrypt", written(t, "notes.txt", "the secret\n"))
|
||||
|
||||
// Ctrl-C on "producer | keyfunc age encrypt -o file" ends the
|
||||
// producer too, so the input ends just as the signal comes, with
|
||||
// enough of it in hand for a whole encryption or decryption. Which
|
||||
// of the two the tool has first varies, so it is tried often, and
|
||||
// a whole file in place is accepted as well as none.
|
||||
for range 25 {
|
||||
named := signalledAsTheInputEnds(t, "encrypt", "the start of the secret\n")
|
||||
if named != "" {
|
||||
require.Equal(t,
|
||||
"the start of the secret\n", run(t, "age", "decrypt", named),
|
||||
)
|
||||
}
|
||||
|
||||
named = signalledAsTheInputEnds(t, "decrypt", sealed)
|
||||
if named != "" {
|
||||
require.Equal(t, "the secret\n", read(t, named))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEncryptionStartedUnderNohupSurvivesAHangup(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
directory := t.TempDir()
|
||||
named := filepath.Join(directory, "notes")
|
||||
|
||||
// nohup starts the tool with SIGHUP ignored. A tool that caught it
|
||||
// anyway would turn it back on and be ended by it.
|
||||
command, producer := writing(
|
||||
t, directory, "the secret\n",
|
||||
"nohup", os.Args[0], "age", "encrypt", "-o", named,
|
||||
)
|
||||
|
||||
require.NoError(t, command.Process.Signal(syscall.SIGHUP))
|
||||
require.NoError(t, producer.Close())
|
||||
waitForTool(t, "SIGHUP under nohup", command)
|
||||
|
||||
require.Equal(t, 0, command.ProcessState.ExitCode())
|
||||
|
||||
left, err := os.ReadDir(directory)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, left, 1)
|
||||
require.Equal(t, "the secret\n", run(t, "age", "decrypt", named))
|
||||
}
|
||||
|
||||
// interrupted runs "age encrypt -o" or "age decrypt -o", as the
|
||||
// operation says, writing into a directory of its own, and once it has
|
||||
// begun writing sends it the signal and leaves the input open. The tool
|
||||
// has to end with status 1 and leave the directory empty. A tool that
|
||||
// went on reading would not end until the input did; one that did not
|
||||
// remove the file it was writing would leave it there, with what it had
|
||||
// written so far.
|
||||
func interrupted(t *testing.T, ending os.Signal, operation, input string) {
|
||||
t.Helper()
|
||||
|
||||
name := operation + " " + ending.String()
|
||||
directory := t.TempDir()
|
||||
|
||||
command, _ := writing(
|
||||
t, directory, input,
|
||||
os.Args[0], "age", operation, "-o", filepath.Join(directory, "notes"),
|
||||
)
|
||||
|
||||
require.NoError(t, command.Process.Signal(ending))
|
||||
waitForTool(t, name, command)
|
||||
|
||||
require.Equal(t, 1, command.ProcessState.ExitCode(), name)
|
||||
|
||||
left, err := os.ReadDir(directory)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, left, name)
|
||||
}
|
||||
|
||||
// signalledAsTheInputEnds runs "age encrypt -o" or "age decrypt -o", as
|
||||
// the operation says, writing into a directory of its own, and once it
|
||||
// has begun writing sends it SIGINT and at once ends its input. Either
|
||||
// the tool ends with status 1 and leaves the directory empty, and ""
|
||||
// is returned, or it ends otherwise and leaves only the named file,
|
||||
// whose path is returned for the caller to check that it is whole.
|
||||
func signalledAsTheInputEnds(t *testing.T, operation, input string) string {
|
||||
t.Helper()
|
||||
|
||||
directory := t.TempDir()
|
||||
named := filepath.Join(directory, "notes")
|
||||
|
||||
command, producer := writing(
|
||||
t, directory, input, os.Args[0], "age", operation, "-o", named,
|
||||
)
|
||||
|
||||
require.NoError(t, command.Process.Signal(syscall.SIGINT))
|
||||
require.NoError(t, producer.Close())
|
||||
waitForTool(t, operation, command)
|
||||
|
||||
left, err := os.ReadDir(directory)
|
||||
require.NoError(t, err)
|
||||
|
||||
if command.ProcessState.ExitCode() == failedStatus {
|
||||
require.Empty(t, left, operation)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
require.Len(t, left, 1, operation)
|
||||
|
||||
return named
|
||||
}
|
||||
|
||||
// receivedAtTheEnd runs "age encrypt -o" or "age decrypt -o", as the
|
||||
// operation says, in this process, over a file that is already there,
|
||||
// with an input that at its end sends this process the signal and waits
|
||||
// until it has been received. The tool has to return ErrInterrupted and
|
||||
// leave that file as it was, with nothing beside it. A tool that went
|
||||
// by the end of the input alone would put its new file in place.
|
||||
func receivedAtTheEnd(
|
||||
t *testing.T, ending syscall.Signal, operation, input string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
name := operation + " " + ending.String()
|
||||
existing := written(t, "notes", "what was already there\n")
|
||||
|
||||
// The test catches the signal as well, so that it does not end the
|
||||
// test binary and so that the input can wait for it.
|
||||
received := make(chan os.Signal, 1)
|
||||
signal.Notify(received, ending)
|
||||
|
||||
defer signal.Stop(received)
|
||||
|
||||
root := cli.Root()
|
||||
root.SetIn(&endingInASignal{
|
||||
rest: strings.NewReader(input), ending: ending, received: received,
|
||||
})
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"age", operation, "-o", existing})
|
||||
|
||||
err := root.ExecuteContext(t.Context())
|
||||
require.ErrorIs(t, err, age.ErrInterrupted, name)
|
||||
|
||||
require.Equal(t, "what was already there\n", read(t, existing), name)
|
||||
|
||||
left, err := os.ReadDir(filepath.Dir(existing))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, left, 1, name)
|
||||
}
|
||||
|
||||
// endingInASignal is an input that, when it runs out, sends this
|
||||
// process its signal and waits for it on received before it reports its
|
||||
// end. It sends the signal only once: once nothing catches it, another
|
||||
// would end the test binary.
|
||||
type endingInASignal struct {
|
||||
rest io.Reader
|
||||
ending syscall.Signal
|
||||
received chan os.Signal
|
||||
sent bool
|
||||
}
|
||||
|
||||
func (input *endingInASignal) Read(buffer []byte) (int, error) {
|
||||
n, err := input.rest.Read(buffer)
|
||||
if !errors.Is(err, io.EOF) || input.sent {
|
||||
return n, err
|
||||
}
|
||||
|
||||
input.sent = true
|
||||
|
||||
err = syscall.Kill(os.Getpid(), input.ending)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
|
||||
<-input.received
|
||||
|
||||
return n, io.EOF
|
||||
}
|
||||
|
||||
// writing starts argv, the tool told to write into directory, as a
|
||||
// subprocess reading the input from a pipe, and returns once the tool
|
||||
// has begun writing the file beside the one it was named. The pipe is
|
||||
// left open for the caller to end.
|
||||
func writing(
|
||||
t *testing.T, directory, input string, argv ...string,
|
||||
) (*exec.Cmd, io.WriteCloser) {
|
||||
t.Helper()
|
||||
|
||||
//nolint:gosec // this test's own binary as the tool, or nohup running it
|
||||
command := exec.CommandContext(t.Context(), argv[0], argv[1:]...)
|
||||
|
||||
command.Env = append(os.Environ(), runAsTool+"=1")
|
||||
|
||||
producer, err := command.StdinPipe()
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, command.Start())
|
||||
|
||||
_, err = io.WriteString(producer, input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The file beside the named one is made once the mnemonic has been
|
||||
// read, before any input is.
|
||||
require.Eventually(t, func() bool {
|
||||
entries, err := os.ReadDir(directory)
|
||||
|
||||
return err == nil && len(entries) > 0
|
||||
}, 5*time.Second, 5*time.Millisecond)
|
||||
|
||||
return command, producer
|
||||
}
|
||||
|
||||
// written puts the contents in a file of that name in a directory of
|
||||
// this test's own and returns the path to it.
|
||||
func written(t *testing.T, name, contents string) string {
|
||||
|
||||
+21
-14
@@ -2,13 +2,11 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/age"
|
||||
@@ -64,24 +62,33 @@ func Root() *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
// init keeps the command on the main thread. Linux hands a signal sent
|
||||
// to the tool to that thread first, and a thread runs a pending signal
|
||||
// handler before its own code, so when "age encrypt -o" or "age
|
||||
// decrypt -o" checks for a signal as its input ends, one sent before
|
||||
// then, as by Ctrl-C on a pipeline, has been received.
|
||||
//
|
||||
//nolint:gochecknoinits // only an init can keep main on the main thread
|
||||
func init() {
|
||||
runtime.LockOSThread()
|
||||
}
|
||||
|
||||
// Main runs the tool and returns the status the process should exit
|
||||
// with. An error ends the tool with status 1, except when it carries a
|
||||
// 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.
|
||||
// SIGINT, SIGTERM and SIGHUP end the tool at once, as they end any Go
|
||||
// program, so a command waiting at the mnemonic prompt or reading what
|
||||
// it encrypts or decrypts goes no further. The exceptions catch the
|
||||
// signals to clean up first: "ssh to" and "ssh install" while they
|
||||
// have ssh or sftp running, so the child ends and their own cleanup
|
||||
// still runs, and "age encrypt -o" and "age decrypt -o" while they
|
||||
// write a new file to rename over the named one, so the unfinished file
|
||||
// is removed.
|
||||
func Main() int {
|
||||
ctx, stop := signal.NotifyContext(
|
||||
context.Background(),
|
||||
syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP,
|
||||
)
|
||||
defer stop()
|
||||
|
||||
err := Root().ExecuteContext(ctx)
|
||||
err := Root().Execute()
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/childmnemonic"
|
||||
"sneak.berlin/go/keyfunc/internal/cli"
|
||||
"sneak.berlin/go/keyfunc/internal/derive"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package signals catches the signals that end the tool, for the
|
||||
// commands that clean up before they end.
|
||||
package signals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Context is signal.NotifyContext for SIGINT, SIGTERM and SIGHUP: the
|
||||
// context it returns is cancelled when one of them arrives, and stop
|
||||
// stops catching them. It leaves out any of the three the tool was
|
||||
// started with set to be ignored, as nohup does with SIGHUP, because
|
||||
// catching a signal turns an ignored one back on and would end a run
|
||||
// that was meant to survive it.
|
||||
func Context(parent context.Context) (context.Context, context.CancelFunc) {
|
||||
endings := caught()
|
||||
|
||||
// Given no signals at all, NotifyContext would catch every one.
|
||||
if len(endings) == 0 {
|
||||
return context.WithCancel(parent)
|
||||
}
|
||||
|
||||
return signal.NotifyContext(parent, endings...)
|
||||
}
|
||||
|
||||
// Notify is signal.Notify for the signals Context catches: each one
|
||||
// that arrives is sent to c, until signal.Stop(c).
|
||||
func Notify(c chan<- os.Signal) {
|
||||
endings := caught()
|
||||
|
||||
// Given no signals at all, Notify would catch every one.
|
||||
if len(endings) > 0 {
|
||||
signal.Notify(c, endings...)
|
||||
}
|
||||
}
|
||||
|
||||
// caught returns those of SIGINT, SIGTERM and SIGHUP that the tool was
|
||||
// not started with set to be ignored.
|
||||
func caught() []os.Signal {
|
||||
endings := []os.Signal{syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP}
|
||||
kept := make([]os.Signal, 0, len(endings))
|
||||
|
||||
for _, ending := range endings {
|
||||
if !signal.Ignored(ending) {
|
||||
kept = append(kept, ending)
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||
)
|
||||
|
||||
// Where the key goes on the host and what the file it arrives in is
|
||||
@@ -33,12 +36,33 @@ const (
|
||||
localMode = 0o600
|
||||
)
|
||||
|
||||
// waitDelay is the WaitDelay sftp runs with: from a signal, or from sftp
|
||||
// ending, how long the tool waits for sftp to end and its output to
|
||||
// close before it kills sftp and stops reading. That is ample for sftp
|
||||
// to stop the ssh it started, and short enough that a signal still ends
|
||||
// the tool within a second.
|
||||
const waitDelay = 250 * time.Millisecond
|
||||
|
||||
// ErrCannotEnter is the refusal of a host whose .ssh is there but
|
||||
// cannot be entered, so that nothing in it can be read or written.
|
||||
var ErrCannotEnter = errors.New(
|
||||
"~/.ssh is there on the host but cannot be entered",
|
||||
)
|
||||
|
||||
// ErrSymlink is the refusal of a host whose authorized_keys is a
|
||||
// symlink: the rename that puts the new file in place would replace the
|
||||
// link itself, and the file it points at would never get the key.
|
||||
var ErrSymlink = errors.New(
|
||||
"~/.ssh/authorized_keys on the host is a symlink, which the tool " +
|
||||
"leaves alone",
|
||||
)
|
||||
|
||||
// ErrStrayArgument is the refusal of anything but the host before --,
|
||||
// which would otherwise be handed to sftp in front of the host.
|
||||
var ErrStrayArgument = errors.New(
|
||||
"only the host goes before --; options for sftp go after --",
|
||||
)
|
||||
|
||||
// install returns the command that adds the public key to a host.
|
||||
func install() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@@ -50,7 +74,22 @@ func install() *cobra.Command {
|
||||
"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),
|
||||
Args: cobra.MatchAll(
|
||||
cobra.MinimumNArgs(1),
|
||||
func(cmd *cobra.Command, args []string) error {
|
||||
// ArgsLenAtDash is -1 when there is no --.
|
||||
before := cmd.ArgsLenAtDash()
|
||||
if before == -1 {
|
||||
before = len(args)
|
||||
}
|
||||
|
||||
if before != 1 {
|
||||
return ErrStrayArgument
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
key, comment, err := derived(cmd)
|
||||
if err != nil {
|
||||
@@ -62,6 +101,14 @@ func install() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// From here on a signal cancels the context, which
|
||||
// sftp runs under, instead of ending the tool, so sftp
|
||||
// ends and the working directory is still removed.
|
||||
ctx, stop := signals.Context(cmd.Context())
|
||||
defer stop()
|
||||
|
||||
cmd.SetContext(ctx)
|
||||
|
||||
return add(cmd, args[0], args[1:], line)
|
||||
},
|
||||
}
|
||||
@@ -178,8 +225,24 @@ func session(
|
||||
command.Stdout = &said
|
||||
command.Stderr = &said
|
||||
|
||||
// A cancelled context means a signal arrived. Send sftp a SIGTERM
|
||||
// rather than the default kill, so it stops the ssh it started
|
||||
// before it goes. Anything sftp started that still holds its output
|
||||
// keeps the tool waiting no longer than waitDelay.
|
||||
command.Cancel = func() error {
|
||||
return command.Process.Signal(syscall.SIGTERM)
|
||||
}
|
||||
command.WaitDelay = waitDelay
|
||||
|
||||
err := command.Run()
|
||||
|
||||
// sftp ended well and only something it started, such as the
|
||||
// master ssh leaves running for ControlPersist under -v, still held
|
||||
// its output: the session worked.
|
||||
if errors.Is(err, exec.ErrWaitDelay) {
|
||||
err = nil
|
||||
}
|
||||
|
||||
_, _ = cmd.ErrOrStderr().Write(said.Bytes())
|
||||
|
||||
if err != nil {
|
||||
@@ -222,14 +285,23 @@ func merge(content, line string) (string, bool) {
|
||||
// it can be looked up, not even ".". The first listing of such a
|
||||
// directory comes up empty, as the server leaves out every name it
|
||||
// cannot look up.
|
||||
//
|
||||
// The first listing is a long one, which shows an authorized_keys that
|
||||
// is a symlink as one. That is refused before anything else sftp said
|
||||
// is read, so a link the get could not follow is refused in the same
|
||||
// words.
|
||||
func fetch(
|
||||
cmd *cobra.Command, host string, options []string, into string,
|
||||
) (string, bool, error) {
|
||||
said, err := session(cmd, host, options, []string{
|
||||
"ls -1 " + directory,
|
||||
"ls -n " + directory,
|
||||
"ls -1 " + directory + "/.",
|
||||
"get " + authorized + " " + quoted(into),
|
||||
})
|
||||
if symlinked(said) {
|
||||
return "", false, ErrSymlink
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if listingNotFound(said, directory) {
|
||||
return "", false, nil
|
||||
@@ -292,6 +364,22 @@ func reportedCannotList(line string) (string, bool) {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(line, before), after), true
|
||||
}
|
||||
|
||||
// symlinked says whether the long listing of .ssh shows authorized_keys
|
||||
// as a symlink. With -n the client writes each line itself, as ls -l
|
||||
// does, whatever the server: the type comes first, "l" for a symlink,
|
||||
// and the path as the listing named it comes last.
|
||||
func symlinked(said string) bool {
|
||||
for line := range strings.Lines(said) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 && strings.HasPrefix(fields[0], "l") &&
|
||||
fields[len(fields)-1] == authorized {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -10,7 +10,7 @@ import "testing"
|
||||
const (
|
||||
echoed = `sftp> get .ssh/authorized_keys "/tmp/keyfunc/authorized_keys"
|
||||
`
|
||||
listed = "sftp> ls -1 .ssh\n"
|
||||
listed = "sftp> ls -n .ssh\n"
|
||||
warning = `Warning: Identity file /gone not accessible: ` +
|
||||
"No such file or directory.\n"
|
||||
)
|
||||
|
||||
+11
-3
@@ -10,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"sneak.berlin/go/keyfunc/internal/cli/signals"
|
||||
)
|
||||
|
||||
// StatusError says the tool should end with the status ssh ended with.
|
||||
@@ -42,7 +43,14 @@ func to() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
served, err := key.Serve(cmd.Context(), comment)
|
||||
// From here until the agent is taken down, a signal
|
||||
// cancels the context instead of ending the tool, so
|
||||
// ssh ends and the socket and its directory are still
|
||||
// removed.
|
||||
ctx, stop := signals.Context(cmd.Context())
|
||||
defer stop()
|
||||
|
||||
served, err := key.Serve(ctx, comment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -53,7 +61,7 @@ func to() *cobra.Command {
|
||||
"-o", "IdentityAgent=" + served.Socket(),
|
||||
}, args)
|
||||
|
||||
return connect(cmd.Context(), argv)
|
||||
return connect(ctx, argv)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -76,7 +84,7 @@ func connect(ctx context.Context, argv []string) error {
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
|
||||
// A cancelled context means a signal ended the tool. Send ssh a
|
||||
// A cancelled context means a signal arrived. 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 {
|
||||
|
||||
+153
-9
@@ -20,13 +20,13 @@ import (
|
||||
|
||||
// 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.
|
||||
// signal tests can drive the real signal path in a process they 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.
|
||||
// set, and otherwise runs the suite. The signal tests start the tool
|
||||
// this way, as a subprocess they can signal and watch end.
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv(runAsTool) == "1" {
|
||||
os.Exit(cli.Main())
|
||||
@@ -99,6 +99,8 @@ const marker = "KEYFUNC_TEST_MARKER"
|
||||
//
|
||||
// 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 long listing (-n) writes each entry as the client does, its type
|
||||
// first, so that a symlink shows as one.
|
||||
// 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,
|
||||
@@ -137,8 +139,7 @@ while IFS= read -r line; do
|
||||
worked=yes
|
||||
case "$1" in
|
||||
ls)
|
||||
dir=$2
|
||||
[ "$dir" = -1 ] && dir=$3
|
||||
dir=$3
|
||||
if [ ! -e "$home/$dir" ]; then
|
||||
worked=no
|
||||
printf 'Can'\''t ls: "%s" not found\n' "$home/$dir" >&2
|
||||
@@ -149,7 +150,13 @@ while IFS= read -r line; do
|
||||
else
|
||||
for entry in "$home/$dir"/*; do
|
||||
[ -e "$entry" ] || continue
|
||||
printf '%s/%s\n' "$dir" "$(basename "$entry")"
|
||||
name="$dir/$(basename "$entry")"
|
||||
if [ "$2" = -n ]; then
|
||||
printf '%s ? someone users 0 Oct 4 15:44 %s\n' \
|
||||
"$(stat -c '%A' "$entry")" "$name"
|
||||
else
|
||||
printf '%s\n' "$name"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
;;
|
||||
@@ -209,6 +216,18 @@ fi
|
||||
sleep 5
|
||||
`
|
||||
|
||||
// stalled is a stand-in for the system sftp that starts a child, notes
|
||||
// it has started, and then blocks, so a test can signal the tool while
|
||||
// sftp is running. The child holds the output the tool reads sftp
|
||||
// through, as the ssh that sftp starts does, and is started before the
|
||||
// note so that it is there when the signal ends the shell and still
|
||||
// holds that output afterwards.
|
||||
const stalled = `
|
||||
sleep 5 &
|
||||
touch "$KEYFUNC_TEST_STARTED"
|
||||
wait
|
||||
`
|
||||
|
||||
// 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.
|
||||
@@ -294,7 +313,7 @@ func TestTheFileIsUploadedBesideTheOldOneAndThenRenamedOverIt(t *testing.T) {
|
||||
|
||||
// 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, "ls -n .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])
|
||||
@@ -360,6 +379,59 @@ func TestADirectoryThatCannotBeEnteredIsRefusedBeforeAnyUpload(t *testing.T) {
|
||||
require.Equal(t, notADirectory, read(t, inTheWay))
|
||||
}
|
||||
|
||||
func TestASymlinkedFileIsRefusedBeforeAnyUpload(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
pretend := pretendHost(t)
|
||||
|
||||
// The file the link points at, which the key would never reach.
|
||||
target := filepath.Join(pretend.home, "keys")
|
||||
require.NoError(t,
|
||||
os.WriteFile(target, []byte("somebody else\n"), fileMode),
|
||||
)
|
||||
|
||||
directory := filepath.Join(pretend.home, keptUnder)
|
||||
require.NoError(t, os.Mkdir(directory, directoryMode))
|
||||
|
||||
link := filepath.Join(directory, keptIn)
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
printed, _, err := attempt(t, host)
|
||||
require.ErrorIs(t, err, ssh.ErrSymlink)
|
||||
require.Empty(t, printed)
|
||||
|
||||
// The read and nothing after it: no upload was tried, the link
|
||||
// still points where it did, and what it points at is unchanged.
|
||||
require.Equal(t, 1, connections(t, pretend))
|
||||
|
||||
pointsAt, err := os.Readlink(link)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, target, pointsAt)
|
||||
require.Equal(t, "somebody else\n", read(t, target))
|
||||
}
|
||||
|
||||
func TestAnArgumentBesideTheHostIsRefusedBeforeAnyConnection(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
pretend := pretendHost(t)
|
||||
|
||||
runs := [][]string{
|
||||
{host, "frank@example.com"},
|
||||
{host, "2222"},
|
||||
{host, "frank@example.com", "--", "-P", "2222"},
|
||||
{"--", host},
|
||||
}
|
||||
|
||||
for _, args := range runs {
|
||||
printed, _, err := attempt(t, args...)
|
||||
require.ErrorIs(t, err, ssh.ErrStrayArgument)
|
||||
require.Empty(t, printed)
|
||||
}
|
||||
|
||||
// sftp was never started, so nothing was uploaded.
|
||||
require.NoFileExists(t, pretend.arguments)
|
||||
}
|
||||
|
||||
func TestAnExistingDirectoryKeepsItsModeAndIsNotRemade(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
@@ -485,6 +557,22 @@ func TestWhatComesAfterTheDashesIsGivenToSFTP(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestAProcessSFTPLeavesBehindDoesNotFailTheRun(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
pretend := pretendHost(t)
|
||||
|
||||
// A session that works ends by leaving a child behind that holds
|
||||
// sftp's output, as the master ssh leaves running for ControlPersist
|
||||
// does under -v.
|
||||
standIn(t, "sftp", installer+"sleep 5 &\n")
|
||||
|
||||
require.Equal(t, "added\n", install(t, host))
|
||||
require.Equal(t, keyLine,
|
||||
read(t, filepath.Join(pretend.home, keptUnder, keptIn)),
|
||||
)
|
||||
}
|
||||
|
||||
func TestSSHIsPointedAtTheAgentAndItsStatusIsHandedOn(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
@@ -544,7 +632,7 @@ func TestASignalTakesTheAgentDirectoryDown(t *testing.T) {
|
||||
// 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
|
||||
// goes through Main and the command's 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) {
|
||||
@@ -611,6 +699,62 @@ func waitForSocket(t *testing.T, noted string) string {
|
||||
return socket
|
||||
}
|
||||
|
||||
func TestASignalTakesTheInstallWorkingDirectoryDown(t *testing.T) {
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
for _, ending := range []os.Signal{
|
||||
syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP,
|
||||
} {
|
||||
signalEndsTheInstall(t, ending.String(), ending)
|
||||
}
|
||||
}
|
||||
|
||||
// signalEndsTheInstall runs "ssh install" as a subprocess against a
|
||||
// stand-in sftp that blocks, with a temporary directory of the test's
|
||||
// own, waits until sftp is running, sends the tool the signal, and
|
||||
// requires the tool to end within a second with status 1 and the
|
||||
// working directory it made there to be gone.
|
||||
func signalEndsTheInstall(t *testing.T, name string, signal os.Signal) {
|
||||
t.Helper()
|
||||
|
||||
temporary := t.TempDir()
|
||||
started := filepath.Join(t.TempDir(), "started")
|
||||
t.Setenv("KEYFUNC_TEST_STARTED", started)
|
||||
standIn(t, "sftp", stalled)
|
||||
|
||||
//nolint:gosec // the binary is this test's own, re-run as the tool
|
||||
command := exec.CommandContext(
|
||||
t.Context(), os.Args[0], subcommand, installing, host,
|
||||
)
|
||||
|
||||
command.Env = append(os.Environ(), runAsTool+"=1", "TMPDIR="+temporary)
|
||||
require.NoError(t, command.Start())
|
||||
|
||||
// sftp is started only once the working directory has been made.
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := os.Stat(started)
|
||||
|
||||
return err == nil
|
||||
}, 5*time.Second, 5*time.Millisecond)
|
||||
|
||||
working, err := os.ReadDir(temporary)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, working, 1, name)
|
||||
|
||||
sent := time.Now()
|
||||
|
||||
require.NoError(t, command.Process.Signal(signal))
|
||||
waitForTool(t, name, command)
|
||||
|
||||
// The child sftp started would hold sftp's output for seconds yet.
|
||||
require.Less(t, time.Since(sent), time.Second, name)
|
||||
require.Equal(t, failedStatus, command.ProcessState.ExitCode(), name)
|
||||
|
||||
left, err := os.ReadDir(temporary)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, left, name)
|
||||
}
|
||||
|
||||
func TestTheMnemonicIsNotHandedToSFTP(t *testing.T) {
|
||||
t.Setenv(mnemonic.CommandVariable, "echo "+example())
|
||||
t.Setenv(mnemonic.Variable, example())
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"git.eeqj.de/sneak/secret/pkg/bip85"
|
||||
"github.com/btcsuite/btcd/btcutil/hdkeychain"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
bip39 "github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/term"
|
||||
"sneak.berlin/go/keyfunc/internal/bip39"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"prettier": "3.8.1"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user