The age commands: pub, priv, encrypt and decrypt (closes #3)
All checks were successful
check / check (push) Successful in 3m57s

The application number is 657169, so the path is
m/83696968'/657169'/<n>'. The 32 derived bytes are clamped the way
X25519 requires and go through bech32 into an age identity, which is
the only route age offers from raw bytes to a key; these are the steps
sneak/secret takes in its agehd package.

The derived recipient is always first in the recipient list, so the
mnemonic that encrypted a file can always read it back. Decrypting
recognises the text form by the line it starts with, so it needs no
flag. A file named with -o is created readable only by its owner,
since a decrypted one is as secret as what went into it.

Model: opus-5
This commit is contained in:
2026-09-07 15:44:08 +00:00
parent 279cba6bcf
commit 73c80ab173
7 changed files with 698 additions and 1 deletions

238
internal/cli/age/age.go Normal file
View File

@@ -0,0 +1,238 @@
// Package age groups the commands that derive age identities and
// encrypt and decrypt with them.
package age
import (
"fmt"
"io"
"os"
"git.eeqj.de/sneak/keyfunc/internal/agekey"
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/derive"
"github.com/spf13/cobra"
)
// ownerOnly is the mode a file the tool creates gets, since a
// decrypted file is as secret as what went into it.
const ownerOnly = 0o600
// 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 and the output the arguments ask for, hands
// them to the work, and closes an output file afterwards either way.
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()
dst, closeDst, err := output(cmd)
if err != nil {
return err
}
err = work(dst, src)
if err != nil {
_ = closeDst()
return err
}
return closeDst()
}
// 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 returns what to write to: the file --output names, or the
// command's own output. The second result closes a file that was
// opened, so a failure to finish writing is not lost.
func output(cmd *cobra.Command) (io.Writer, func() error, error) {
name, err := cmd.Flags().GetString("output")
if err != nil {
return nil, nil, fmt.Errorf("reading the output file: %w", err)
}
if name == "" {
return cmd.OutOrStdout(), func() error { return nil }, nil
}
//nolint:gosec // writing the file the user named is the point
file, err := os.OpenFile(
name, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, ownerOnly,
)
if err != nil {
return nil, nil, fmt.Errorf("creating %s: %w", name, err)
}
return file, file.Close, 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)
}

84
internal/cli/age_test.go Normal file
View File

@@ -0,0 +1,84 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.eeqj.de/sneak/keyfunc/internal/agekey"
"git.eeqj.de/sneak/keyfunc/internal/mnemonic"
"github.com/stretchr/testify/require"
)
func TestTheAgeCommandsPrintTheKey(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
recipient := strings.TrimSpace(run(t, "age", "pub"))
require.True(t, strings.HasPrefix(recipient, "age1"))
identity := strings.TrimSpace(run(t, "age", "priv"))
require.True(t, strings.HasPrefix(identity, "AGE-SECRET-KEY-1"))
}
func TestAFileEncryptedByTheToolIsReadBackByIt(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-o", sealed, plain)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
}
func TestTheArmoredFormIsTextThatDecrypts(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
armored := run(t, "age", "encrypt", "--armor", plain)
require.True(t, strings.HasPrefix(
armored, "-----BEGIN AGE ENCRYPTED FILE-----",
))
sealed := written(t, "notes.age", armored)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
}
func TestAnotherRecipientIsAddedAndTheDerivedOneStays(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
theirs := strings.TrimSpace(run(t, "age", "pub", "-n", "7"))
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "--to", theirs, "-o", sealed, plain)
require.Equal(t, "the secret\n", run(t, "age", "decrypt", sealed))
require.Equal(t,
"the secret\n", run(t, "age", "decrypt", "-n", "7", sealed),
)
}
func TestAFileForAnotherKeyIsRefused(t *testing.T) {
t.Setenv(mnemonic.Variable, example())
plain := written(t, "notes.txt", "the secret\n")
sealed := filepath.Join(t.TempDir(), "notes.age")
run(t, "age", "encrypt", "-n", "7", "-o", sealed, plain)
_, err := execute(t, "age", "decrypt", sealed)
require.ErrorIs(t, err, agekey.ErrNotRecipient)
}
// 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 {
t.Helper()
path := filepath.Join(t.TempDir(), name)
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
return path
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"git.eeqj.de/sneak/keyfunc/internal/cli/age"
"git.eeqj.de/sneak/keyfunc/internal/cli/options"
"git.eeqj.de/sneak/keyfunc/internal/cli/ssh"
"github.com/spf13/cobra"
@@ -29,7 +30,7 @@ func Root() *cobra.Command {
}
options.Add(root)
root.AddCommand(ssh.Command())
root.AddCommand(ssh.Command(), age.Command())
return root
}