Files
keyfunc/internal/cli/age/age.go
T
sneak c3cc26c721
check / check (push) Waiting to run
Put the terminal back when a signal ends the mnemonic prompt (closes #91)
A SIGINT, SIGTERM or SIGHUP at the mnemonic prompt killed the tool
with echo still off. The prompt now catches them with signals.Notify
from before echo goes off, and reads the line in a goroutine. Once the
line is read or a signal arrives, it puts the terminal settings back
and stops catching; if a signal has arrived by then, it sends the tool
the same signal again, so the tool is still ended by it (130 for SIGINT
in a shell). A test sends SIGINT at the prompt on a pseudo-terminal.

The signals package moves from internal/cli to internal/signals, since
the mnemonic prompt now uses it as well.

Unchanged: the Windows prompt, which uses term.ReadPassword.

Model: opus-5-5
2026-10-14 23:05:41 +00:00

471 lines
12 KiB
Go

// Package age groups the commands that derive age identities and
// encrypt and decrypt with them.
package age
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/spf13/cobra"
"sneak.berlin/go/keyfunc/internal/agekey"
"sneak.berlin/go/keyfunc/internal/cli/options"
"sneak.berlin/go/keyfunc/internal/derive"
"sneak.berlin/go/keyfunc/internal/signals"
)
// 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{
Use: "age",
Short: "derive age identities and encrypt and decrypt with them",
}
group.AddCommand(public(), private(), encrypt(), decrypt())
return group
}
// public returns the command that prints the recipient.
func public() *cobra.Command {
return &cobra.Command{
Use: "pub",
Short: "print the recipient, the age1... public key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
key, err := derived(cmd)
if err != nil {
return err
}
return write(cmd, key.Recipient())
},
}
}
// private returns the command that prints the identity.
func private() *cobra.Command {
return &cobra.Command{
Use: "priv",
Short: "print the identity, the AGE-SECRET-KEY-1... line",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
key, err := derived(cmd)
if err != nil {
return err
}
return write(cmd, key.Identity())
},
}
}
// encrypt returns the command that encrypts a file or standard input.
func encrypt() *cobra.Command {
cmd := &cobra.Command{
Use: "encrypt [file]",
Short: "encrypt to the derived recipient and any others given",
Args: cobra.MaximumNArgs(1),
RunE: runEncrypt,
}
cmd.Flags().StringArray(
"to", nil,
"another recipient to encrypt to, as well as the derived one",
)
cmd.Flags().Bool(
"armor", false,
"write the text form instead of the binary one",
)
addOutput(cmd)
return cmd
}
// decrypt returns the command that decrypts a file or standard input.
func decrypt() *cobra.Command {
cmd := &cobra.Command{
Use: "decrypt [file]",
Short: "decrypt with the derived identity",
Args: cobra.MaximumNArgs(1),
RunE: runDecrypt,
}
addOutput(cmd)
return cmd
}
// runEncrypt encrypts to the derived recipient and any others given.
// 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 {
return fmt.Errorf("reading the recipients: %w", err)
}
to, err := agekey.Recipients(names)
if err != nil {
return err
}
armored, err := cmd.Flags().GetBool("armor")
if err != nil {
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
}
defer closeSrc()
key, err := derived(cmd)
if err != nil {
return err
}
return through(cmd, src, func(dst io.Writer, plain io.Reader) error {
return key.Encrypt(dst, plain, to, armored)
})
}
// 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
}
defer closeSrc()
key, err := derived(cmd)
if err != nil {
return err
}
return through(cmd, src, key.Decrypt)
}
// through hands the input 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, src io.Reader,
work func(io.Writer, io.Reader) error,
) error {
name, err := cmd.Flags().GetString("output")
if err != nil {
return fmt.Errorf("reading the output file: %w", err)
}
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)
}
}
// 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
// own input when no file is named. The second result closes a file
// that was opened and does nothing otherwise.
func input(cmd *cobra.Command, args []string) (io.Reader, func(), error) {
if len(args) == 0 {
return cmd.InOrStdin(), func() {}, nil
}
file, err := os.Open(args[0])
if err != nil {
return nil, nil, fmt.Errorf("opening %s: %w", args[0], err)
}
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:
//
// - 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 fmt.Errorf("following %s: %w", name, err)
}
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 fmt.Errorf("creating a file beside %s: %w", name, err)
}
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)
}
case <-interrupted.Done():
}
return finish(file, name, ErrInterrupted)
}
// finish closes the new file and puts it in the named file's place, or
// throws it away when the work failed. It returns the error the caller
// should report.
func finish(file *os.File, name string, failed error) error {
closeErr := file.Close()
if failed != nil || closeErr != nil {
_ = os.Remove(file.Name())
if failed != nil {
return failed
}
return fmt.Errorf("finishing %s: %w", name, closeErr)
}
err := os.Rename(file.Name(), name)
if err != nil {
_ = os.Remove(file.Name())
return fmt.Errorf("putting %s in place: %w", name, err)
}
return nil
}
// addOutput gives a command its output file flag.
func addOutput(cmd *cobra.Command) {
cmd.Flags().StringP(
"output", "o", "",
"write to this file instead of standard output",
)
}
// write sends one line to wherever the command's output goes.
func write(cmd *cobra.Command, line string) error {
_, err := fmt.Fprintln(cmd.OutOrStdout(), line)
if err != nil {
return fmt.Errorf("writing the key: %w", err)
}
return nil
}
// derived returns the age key for this run.
func derived(cmd *cobra.Command) (*agekey.Key, error) {
index, err := options.Index(cmd)
if err != nil {
return nil, err
}
words, err := options.Mnemonic(cmd)
if err != nil {
return nil, err
}
material, err := derive.Bytes(words, agekey.Application, index)
if err != nil {
return nil, err
}
return agekey.New(material)
}