3 Commits
Author SHA1 Message Date
clawbot d3ca2e0d57 End the mnemonic prompt at end of input (closes #78)
check / check (push) Waiting to run
term.ReadPassword reads the terminal with a raw read that returns 0
bytes at the end of input and takes that as nothing yet, so Ctrl-D at
the prompt did nothing. On the Unix systems the prompt now sets the
terminal as ReadPassword does and reads the line from standard input,
which reports the end of input; elsewhere, such as Windows, it still
uses ReadPassword. Ctrl-D with nothing typed gives an empty mnemonic,
refused with status 1, and the terminal settings are put back.

A test runs the tool on a pseudo-terminal (github.com/creack/pty, at a
commit past v1.1.24, whose Open on Linux can return another pty's
terminal) and types Ctrl-D at the prompt.

Model: opus-5-5
2026-10-14 21:09:14 +00:00
clawbot 2fbc32fe75 Check the -o path before looking for the mnemonic (closes #87)
check / check (push) Waiting to run
age encrypt and age decrypt looked at the -o path only after the
mnemonic had been obtained, so a path that could never be written
still prompted for the mnemonic or ran the mnemonic command. Both now
refuse, before the key is derived, an -o path that is a directory or a
symlink to one, whose directory is missing or is not a directory, or
that is a symlink pointing at nothing. The check creates nothing;
output still looks at the path when it writes, so the file beside the
target is made when it was before. The README names the -o path among
what is checked first.

Left out: a directory the tool may not write to is found only when it
writes.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 23:07:07 +02:00
clawbot f1e27814cd Read the text form with blank lines in front, as age does (closes #77)
check / check (push) Waiting to run
age decrypt took the text form only when the file began exactly with
-----BEGIN AGE ENCRYPTED FILE-----, so a blank line in front made it
read the file as the binary form and refuse it. It now looks for that
line past up to 1024 bytes of whitespace, as the age tool does, and
the text form reader skips the blank lines. The README's age decrypt
section says so.

Judgement call: that first line with spaces in front of it on the same
line is still refused, as age refuses it.

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-14 22:35:29 +02:00
9 changed files with 216 additions and 44 deletions
+13 -8
View File
@@ -117,9 +117,10 @@ order; the first one found wins:
held in the environment.
3. Environment variable `KEYFUNC_MNEMONIC`: the mnemonic itself.
4. A prompt on the terminal with echo turned off. The mnemonic is what is typed
up to Enter or the end of input (Ctrl-D), and echo is turned back on once it
is read. Ctrl-D with nothing typed leaves the mnemonic empty, so the tool
refuses it and exits with status 1.
up to Enter, and echo is turned back on once it is read. Ctrl-D with nothing
typed ends the prompt at once and leaves the mnemonic empty, so the tool
refuses it and exits with status 1. After text has been typed, Ctrl-D has to
be pressed twice to end the prompt, and the text typed is the mnemonic.
If none of these is available and standard input is not a terminal, the tool
refuses and exits with status 1. A mnemonic that is empty, does not have 12, 15,
@@ -129,10 +130,12 @@ the list is named by its position, never printed. Keys are derived from the
mnemonic's words joined by single spaces, whatever whitespace is around or
between them, so one word per line, tabs or extra spaces give the same keys.
The key index, `--words`, the `--to` recipients and the file `age encrypt` or
`age decrypt` is given to read are checked before the mnemonic is looked for, so
a run refused for one of them never prompts for the mnemonic or runs the
mnemonic command.
The key index, `--words`, the `--to` recipients, the file `age encrypt` or
`age decrypt` is given to read and its `-o` path are checked before the mnemonic
is looked for, so a run refused for one of them never prompts for the mnemonic
or runs the mnemonic command. An `-o` path is refused at that point if it is a
directory or a symlink to one, if its directory is missing or is not a
directory, or if it is a symlink that points at nothing.
`KEYFUNC_MNEMONIC` and `KEYFUNC_MNEMONIC_COMMAND` are removed from the
environment before the mnemonic command, the system `ssh` (`keyfunc ssh to`) and
@@ -286,7 +289,9 @@ or a device, such as `/dev/null`, is written to directly.
Decrypts the file (or standard input) with the derived identity. Output goes to
`-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.
not one of the recipients, the tool says so and exits with status 1. The text
form needs no flag, and up to 1024 bytes of blank lines in front of it are
skipped, as `age` skips them.
## Derived mnemonics: `keyfunc mnemonic`
+10 -3
View File
@@ -4,6 +4,7 @@ package agekey
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
@@ -187,12 +188,18 @@ func (k *Key) Decrypt(dst io.Writer, src io.Reader) error {
}
// unarmored strips the text form when the input begins with the line
// that starts one, and leaves binary input alone.
// that starts one, and leaves binary input alone. Like age, it looks
// past up to 1024 bytes of whitespace for that line, and the text form
// reader skips blank lines in front of it.
func unarmored(src io.Reader) io.Reader {
const maxWhitespace = 1024
buffered := bufio.NewReader(src)
start, err := buffered.Peek(len(armor.Header))
if err == nil && string(start) == armor.Header {
// A shorter input gives an error along with all of it, which is
// enough to look at.
start, _ := buffered.Peek(maxWhitespace + len(armor.Header))
if bytes.HasPrefix(bytes.TrimSpace(start), []byte(armor.Header)) {
return armor.NewReader(buffered)
}
+22
View File
@@ -2,6 +2,7 @@ package agekey_test
import (
"bytes"
"io"
"strings"
"testing"
@@ -99,6 +100,27 @@ func TestTheArmoredFormIsText(t *testing.T) {
))
}
func TestTheTextFormIsReadWithWhitespaceInFront(t *testing.T) {
t.Parallel()
key := forIndex(t, 0)
var sealed bytes.Buffer
err := key.Encrypt(&sealed, strings.NewReader("hello"), nil, true)
require.NoError(t, err)
// A blank line and a line of two spaces, as pasting can leave. age
// refuses spaces on the first line of the text form itself, and so
// does this.
pasted := io.MultiReader(strings.NewReader("\n \n"), &sealed)
var opened bytes.Buffer
require.NoError(t, key.Decrypt(&opened, pasted))
require.Equal(t, "hello", opened.String())
}
func TestAFileForSomebodyElseIsRefused(t *testing.T) {
t.Parallel()
+62 -5
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey"
@@ -108,9 +109,9 @@ func decrypt() *cobra.Command {
}
// runEncrypt encrypts to the derived recipient and any others given.
// The recipients are read and the input opened before the key is
// derived, so that a run refused for either never asks for the
// mnemonic.
// The recipients are read, the -o path looked at and the input opened
// before the key is derived, so that a run refused for any of them
// never asks for the mnemonic.
func runEncrypt(cmd *cobra.Command, args []string) error {
names, err := cmd.Flags().GetStringArray("to")
if err != nil {
@@ -127,6 +128,11 @@ func runEncrypt(cmd *cobra.Command, args []string) error {
return fmt.Errorf("reading the armor flag: %w", err)
}
err = checkOutput(cmd)
if err != nil {
return err
}
src, closeSrc, err := input(cmd, args)
if err != nil {
return err
@@ -144,9 +150,14 @@ func runEncrypt(cmd *cobra.Command, args []string) error {
})
}
// runDecrypt decrypts with the derived identity. The input is opened
// before the key is derived, as for runEncrypt.
// runDecrypt decrypts with the derived identity. The -o path is looked
// at and the input opened before the key is derived, as for runEncrypt.
func runDecrypt(cmd *cobra.Command, args []string) error {
err := checkOutput(cmd)
if err != nil {
return err
}
src, closeSrc, err := input(cmd, args)
if err != nil {
return err
@@ -225,6 +236,52 @@ func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
return file, func() { _ = file.Close() }, nil
}
// checkOutput refuses an -o path that output could not write to,
// before anything is written: a directory or a symlink to one, one
// whose directory is missing or is not a directory, or a symlink that
// points at nothing. It creates nothing; output looks at the path again
// when it writes.
func checkOutput(cmd *cobra.Command) error {
name, err := cmd.Flags().GetString("output")
if err != nil {
return fmt.Errorf("reading the output file: %w", err)
}
if name == "" {
return nil
}
info, err := os.Lstat(name)
switch {
case errors.Is(err, fs.ErrNotExist):
// replace makes its new file in this directory. When the
// directory is a file instead, os.Lstat has already failed with
// ENOTDIR, the case below.
directory := filepath.Dir(name)
_, err = os.Stat(directory)
if err != nil {
return fmt.Errorf("looking at %s: %w", directory, err)
}
return nil
case err != nil:
return fmt.Errorf("looking at %s: %w", name, err)
case info.Mode().Type() == fs.ModeSymlink:
info, err = os.Stat(name)
if err != nil {
return fmt.Errorf("following %s: %w", name, err)
}
}
if info.IsDir() {
return fmt.Errorf("writing to %s: %w", name, syscall.EISDIR)
}
return nil
}
// output has the work write to the named path, going by what is there
// without following a final symlink:
//
+38
View File
@@ -108,6 +108,44 @@ func TestAnInputFileThatCannotBeOpenedIsRefused(t *testing.T) {
}
}
// The mnemonic command fails in these runs, so the refusal of the -o
// path shows that it was looked at before the mnemonic was looked for.
func TestAnOutputPathThatCannotBeWrittenIsRefused(t *testing.T) {
t.Setenv(mnemonic.CommandVariable, failingMnemonicCommand)
plain := written(t, "notes.txt", "the secret\n")
directory := t.TempDir()
missing := filepath.Join(directory, "missing")
dangling := filepath.Join(directory, "dangling")
require.NoError(t, os.Symlink(missing, dangling))
toDirectory := filepath.Join(directory, "to-directory")
require.NoError(t, os.Symlink(t.TempDir(), toDirectory))
refusals := map[string]error{
filepath.Join(missing, "notes.age"): fs.ErrNotExist,
filepath.Join(plain, "notes.age"): syscall.ENOTDIR,
dangling: fs.ErrNotExist,
directory: syscall.EISDIR,
toDirectory: syscall.EISDIR,
}
for name, refusal := range refusals {
out, err := execute(t, "age", "encrypt", "-o", name, plain)
require.ErrorIs(t, err, refusal, name)
require.Empty(t, out, name)
out, err = execute(t, "age", "decrypt", "-o", name, plain)
require.ErrorIs(t, err, refusal, name)
require.Empty(t, out, name)
}
// Nothing was made beside the two symlinks.
left, err := os.ReadDir(directory)
require.NoError(t, err)
require.Len(t, left, 2)
}
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
+3 -28
View File
@@ -2,18 +2,15 @@
package mnemonic
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"slices"
"strings"
"golang.org/x/sys/unix"
"golang.org/x/term"
"sneak.berlin/go/keyfunc/internal/bip39"
)
@@ -129,10 +126,7 @@ func ChildEnv() []string {
return kept
}
// ask prompts on the terminal with echo turned off and reads one line,
// which ends at a newline or at the end of input (Ctrl-D). The terminal
// is set as term.ReadPassword sets it; ReadPassword itself is not used
// because it never sees the end of input and goes on waiting.
// ask prompts on the terminal with echo turned off.
func ask() (string, error) {
fd := int(os.Stdin.Fd())
@@ -140,32 +134,13 @@ func ask() (string, error) {
return "", ErrMissing
}
settings, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return "", fmt.Errorf("reading the terminal settings: %w", err)
}
hidden := *settings
hidden.Lflag &^= unix.ECHO
hidden.Lflag |= unix.ICANON | unix.ISIG
hidden.Iflag |= unix.ICRNL
err = unix.IoctlSetTermios(fd, ioctlWriteTermios, &hidden)
if err != nil {
return "", fmt.Errorf("turning echo off: %w", err)
}
defer func() { _ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings) }()
fmt.Fprint(os.Stderr, "mnemonic: ")
// With ICANON set, a read from the terminal returns at most one
// line, so the buffer takes nothing past the newline.
typed, err := bufio.NewReader(os.Stdin).ReadString('\n')
typed, err := readWithoutEcho()
fmt.Fprintln(os.Stderr)
if err != nil && !errors.Is(err, io.EOF) {
if err != nil {
return "", fmt.Errorf("reading the mnemonic: %w", err)
}
+18
View File
@@ -0,0 +1,18 @@
//go:build !aix && !darwin && !dragonfly && !freebsd && !linux && !netbsd && !openbsd && !solaris && !zos
package mnemonic
import (
"os"
"golang.org/x/term"
)
// readWithoutEcho reads one line from the terminal on standard input
// with echo turned off, through term.ReadPassword, on the systems
// prompt_unix.go is not built for, such as Windows.
func readWithoutEcho() (string, error) {
typed, err := term.ReadPassword(int(os.Stdin.Fd()))
return string(typed), err
}
+48
View File
@@ -0,0 +1,48 @@
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || zos
package mnemonic
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"golang.org/x/sys/unix"
)
// readWithoutEcho reads one line from the terminal on standard input
// with echo turned off. The line ends at a newline or at the end of
// input (Ctrl-D). The terminal is set as term.ReadPassword sets it;
// ReadPassword itself is not used here because it never sees the end
// of input and goes on waiting.
func readWithoutEcho() (string, error) {
fd := int(os.Stdin.Fd())
settings, err := unix.IoctlGetTermios(fd, ioctlReadTermios)
if err != nil {
return "", fmt.Errorf("reading the terminal settings: %w", err)
}
hidden := *settings
hidden.Lflag &^= unix.ECHO
hidden.Lflag |= unix.ICANON | unix.ISIG
hidden.Iflag |= unix.ICRNL
err = unix.IoctlSetTermios(fd, ioctlWriteTermios, &hidden)
if err != nil {
return "", fmt.Errorf("turning echo off: %w", err)
}
defer func() { _ = unix.IoctlSetTermios(fd, ioctlWriteTermios, settings) }()
// With ICANON set, a read from the terminal returns at most one
// line, so the buffer takes nothing past the newline.
typed, err := bufio.NewReader(os.Stdin).ReadString('\n')
if errors.Is(err, io.EOF) {
return typed, nil
}
return typed, err
}
@@ -1,3 +1,5 @@
//go:build aix || linux || solaris || zos
package mnemonic
import "golang.org/x/sys/unix"