// 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" "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{ 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. func runEncrypt(cmd *cobra.Command, args []string) error { key, err := derived(cmd) if err != nil { return err } to, err := cmd.Flags().GetStringArray("to") if err != nil { return fmt.Errorf("reading the recipients: %w", err) } armored, err := cmd.Flags().GetBool("armor") if err != nil { return fmt.Errorf("reading the armor flag: %w", err) } return through(cmd, args, func(dst io.Writer, src io.Reader) error { return key.Encrypt(dst, src, to, armored) }) } // runDecrypt decrypts with the derived identity. func runDecrypt(cmd *cobra.Command, args []string) error { key, err := derived(cmd) if err != nil { return err } return through(cmd, args, key.Decrypt) } // 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, ) error { src, closeSrc, err := input(cmd, args) if err != nil { return err } defer closeSrc() 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 } // 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) }