From da5adc982826469851f64a79014a5e3d2aeed4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lilian=20J=C3=B3nsd=C3=B3ttir?= Date: Sat, 23 Mar 2024 14:07:48 -0700 Subject: [PATCH] add support for encrypting to multiple recipients / decrypting with multiple identities - config options for reading identities/recipients from file - command line flags for both files, and identities/recipients straight from the command line - some cleanup and better help strings --- cmd/agedit/cli.go | 138 ++++++++++++++++++++++++++++-------- cmd/agedit/main.go | 28 ++++---- pkg/encrypt/encrypt.go | 12 ++-- pkg/encrypt/encrypt_test.go | 103 ++++++++++++++++++++++----- 4 files changed, 216 insertions(+), 65 deletions(-) diff --git a/cmd/agedit/cli.go b/cmd/agedit/cli.go index 607d5e5..a7982e3 100644 --- a/cmd/agedit/cli.go +++ b/cmd/agedit/cli.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "filippo.io/age" "git.burning.moe/celediel/agedit/internal/config" "git.burning.moe/celediel/agedit/pkg/editor" "git.burning.moe/celediel/agedit/pkg/encrypt" @@ -46,26 +47,78 @@ var ( }} flags = []cli.Flag{ - &cli.StringFlag{ + &cli.StringSliceFlag{ Name: "identity", - Usage: "age identity file to decrypt with", + Usage: "age `identity` (or identities) to decrypt with", + Aliases: []string{"I"}, + Action: func(ctx *cli.Context, inputs []string) error { + for _, input := range inputs { + id, err := age.ParseX25519Identity(input) + if err != nil { + return err + } + + identities = append(identities, id) + } + return nil + }, + }, + &cli.PathFlag{ // I dunno why PathFlag exists because cli.Path is just string + Name: "identity-file", + Usage: "read identity from `FILE`", Aliases: []string{"i"}, - Action: func(ctx *cli.Context, identity_file string) error { + Action: func(ctx *cli.Context, identity_file cli.Path) error { if identity_file != "" { cfg.IdentityFile = identity_file } return nil }, }, + &cli.StringSliceFlag{ + Name: "recipient", + Usage: "age `recipient`s to encrypt to", + Aliases: []string{"R"}, + Action: func(ctx *cli.Context, inputs []string) error { + for _, input := range inputs { + logger.Debugf("parsing public key from string %s", input) + r, err := age.ParseX25519Recipient(input) + if err != nil { + return err + } + recipients = append(recipients, r) + } + return nil + }, + }, + &cli.PathFlag{ + Name: "recipient-file", + Usage: "read recipients from `FILE`", + Aliases: []string{"r"}, + Action: func(ctx *cli.Context, recipient_file cli.Path) error { + if recipient_file != "" { + cfg.RecipientFile = recipient_file + } + return nil + }, + }, &cli.StringFlag{ Name: "out", - Usage: "write to this file instead of the input file", + Usage: "write to `FILE` instead of the input file", Aliases: []string{"o"}, Action: func(ctx *cli.Context, out string) error { output_file = out return nil }, }, + &cli.StringFlag{ + Name: "editor", + Usage: "edit with specified `EDITOR` instead of $EDITOR", + Aliases: []string{"e"}, + Action: func(ctx *cli.Context, editor string) error { + cfg.Editor = editor + return nil + }, + }, &cli.StringSliceFlag{ Name: "editor-args", Usage: "`arg`uments to send to the editor", @@ -76,7 +129,7 @@ var ( }, &cli.BoolFlag{ Name: "force", - Usage: "Re-encrypt the file even if no changes have been made.", + Usage: "re-encrypt the file even if no changes have been made.", Aliases: []string{"f"}, Action: func(ctx *cli.Context, b bool) error { force_overwrite = b @@ -84,10 +137,9 @@ var ( }, }, &cli.StringFlag{ - Name: "log", - Usage: "log level", - Value: "warn", - Aliases: []string{"l"}, + Name: "log", + Usage: "log `level`", + Value: "warn", Action: func(ctx *cli.Context, s string) error { if lvl, err := log.ParseLevel(s); err == nil { logger.SetLevel(lvl) @@ -101,15 +153,6 @@ var ( return nil }, }, - &cli.StringFlag{ - Name: "editor", - Usage: "specify the editor to use", - Aliases: []string{"e"}, - Action: func(ctx *cli.Context, editor string) error { - cfg.Editor = editor - return nil - }, - }, } ) @@ -120,7 +163,7 @@ func before(ctx *cli.Context) error { return fmt.Errorf("no file to edit, use " + name + " -h for help") } - // do some setup + // set some defaults cfg = config.Defaults cfg.Editor = env.GetEditor() cfg_dir := env.GetConfigDir(name) @@ -150,6 +193,7 @@ func before(ctx *cli.Context) error { // action does the actual thing func action(ctx *cli.Context) error { + // make sure input file exists if _, err := os.Stat(input_file); os.IsNotExist(err) { return err } @@ -159,18 +203,55 @@ func action(ctx *cli.Context) error { logger.Debug("out file not specified, using input", "outfile", output_file) } - if _, err := os.Stat(cfg.IdentityFile); os.IsNotExist(err) { - return fmt.Errorf("identity file unset, use -i or set one in the config file") + // read from identity file if exists and no identities have been supplied + if len(identities) == 0 { + if _, err := os.Stat(cfg.IdentityFile); os.IsNotExist(err) { + return fmt.Errorf("identity file unset and no identities supplied, use -i to specify an idenitity file or set one in the config file, or use -I to specify an age private key") + } else { + f, err := os.Open(cfg.IdentityFile) + if err != nil { + return fmt.Errorf("couldn't open identity file: %v", err) + } + if ids, err := age.ParseIdentities(f); err != nil { + return fmt.Errorf("couldn't parse identities: %v", err) + } else { + identities = append(identities, ids...) + } + } } - if id, err := encrypt.ReadIdentityFromFile(cfg.IdentityFile); err != nil { - return err - } else { - identity = id + // read from recipient file if it exists and no recipients have been supplied + if len(recipients) == 0 { + if _, err := os.Stat(cfg.RecipientFile); os.IsNotExist(err) { + return fmt.Errorf("recipient file doesn't exist") + } else { + f, err := os.Open(cfg.RecipientFile) + if err != nil { + return fmt.Errorf("couldn't open recipient file: %v", err) + } + if rs, err := age.ParseRecipients(f); err != nil { + return fmt.Errorf("couldn't parse recipients: %v", err) + } else { + recipients = append(recipients, rs...) + } + } } - logger.Debug("read identity from file", "id", identity.Recipient()) - decrypted, err := encrypt.Decrypt(input_file, identity) + // get recipients from specified identities + for _, id := range identities { + // TODO: figure out how age actually intends for + // TODO: a recpient to be retrieved from an age.Identity + // TODO: beccause this is stupid and I hate it + actual_id, err := age.ParseX25519Identity(fmt.Sprint(id)) + if err != nil { + return fmt.Errorf("couldn't get recipient? %v", err) + } + + recipients = append(recipients, actual_id.Recipient()) + } + + // try to decrypt the file + decrypted, err := encrypt.Decrypt(input_file, identities...) if err != nil { return err } @@ -189,7 +270,8 @@ func action(ctx *cli.Context) error { return nil } - err = encrypt.Encrypt(edited, output_file, identity) + // actually re-encrypt the data + err = encrypt.Encrypt(edited, output_file, recipients...) if err != nil { return err } diff --git a/cmd/agedit/main.go b/cmd/agedit/main.go index 6e6ef3b..eb3a2aa 100644 --- a/cmd/agedit/main.go +++ b/cmd/agedit/main.go @@ -4,6 +4,7 @@ import ( "os" "git.burning.moe/celediel/agedit/internal/config" + "git.burning.moe/celediel/agedit/pkg/editor" "filippo.io/age" "github.com/charmbracelet/log" @@ -11,26 +12,27 @@ import ( ) var ( - identity *age.X25519Identity - logger *log.Logger - cfg config.Config - configFile string - + identities []age.Identity + recipients []age.Recipient + logger *log.Logger + cfg config.Config edt editor.Editor + configFile string input_file, output_file string force_overwrite bool ) func main() { app := &cli.App{ - Name: name, - Usage: usage, - Version: version, - Authors: authors, - Flags: flags, - Before: before, - Action: action, - CustomAppHelpTemplate: help_template, + Name: name, + Usage: usage, + Version: version, + Authors: authors, + Flags: flags, + Before: before, + Action: action, + CustomAppHelpTemplate: help_template, + UseShortOptionHandling: true, } if err := app.Run(os.Args); err != nil { diff --git a/pkg/encrypt/encrypt.go b/pkg/encrypt/encrypt.go index b950dd7..152fedb 100644 --- a/pkg/encrypt/encrypt.go +++ b/pkg/encrypt/encrypt.go @@ -11,18 +11,18 @@ import ( ) // Encrypt encrypts bytes into filename -func Encrypt(data []byte, filename string, identity *age.X25519Identity) error { +func Encrypt(data []byte, filename string, recipients ...age.Recipient) error { var ( w io.WriteCloser out = &bytes.Buffer{} err error ) - if identity == nil { - return errors.New("nil identity??") + if len(recipients) == 0 { + return errors.New("no recepients? who's trying to encrypt?") } - if w, err = age.Encrypt(out, identity.Recipient()); err != nil { + if w, err = age.Encrypt(out, recipients...); err != nil { return err } @@ -40,7 +40,7 @@ func Encrypt(data []byte, filename string, identity *age.X25519Identity) error { } // Decrypt decrypts bytes from filename -func Decrypt(filename string, identity *age.X25519Identity) ([]byte, error) { +func Decrypt(filename string, identities ...age.Identity) ([]byte, error) { var ( f *os.File r io.Reader @@ -51,7 +51,7 @@ func Decrypt(filename string, identity *age.X25519Identity) ([]byte, error) { return nil, err } - if r, err = age.Decrypt(f, identity); err != nil { + if r, err = age.Decrypt(f, identities...); err != nil { return nil, err } diff --git a/pkg/encrypt/encrypt_test.go b/pkg/encrypt/encrypt_test.go index 03a7af5..157b9ff 100644 --- a/pkg/encrypt/encrypt_test.go +++ b/pkg/encrypt/encrypt_test.go @@ -5,29 +5,29 @@ import ( "os" "testing" + "filippo.io/age" "git.burning.moe/celediel/agedit/pkg/tmpfile" ) -var generator = tmpfile.NewGenerator("test_", ".txt", 18) +var ( + generator = tmpfile.NewGenerator("test_", ".txt", 18) + strings_to_write = []string{ + "hello world", + "hola mundo", + "مرحبا بالعالم", + "こんにちは世界", + "你好世界", + "Γειά σου Κόσμε", + "Привіт Світ", + "Բարեւ աշխարհ", + "გამარჯობა მსოფლიო", + "अभिवादन पृथ्वी", + } +) // TestEncryptionDecryption writes a string to a file, encrypts it, then decrypts it, and reads the string. func TestEncryptionDecryption(t *testing.T) { - var ( - strings_to_write = []string{ - "hello world", - "hola mundo", - "مرحبا بالعالم", - "こんにちは世界", - "你好世界", - "Γειά σου Κόσμε", - "Привіт Світ", - "Բարեւ աշխարհ", - "გამარჯობა მსოფლიო", - "अभिवादन पृथ्वी", - } - ) - - id, err := NewIdentity() + id, err := age.GenerateX25519Identity() if err != nil { t.Fatal(err) } @@ -49,7 +49,7 @@ func TestEncryptionDecryption(t *testing.T) { t.Fatal(err) } - if err = Encrypt(b, encrypted_outname, id); err != nil { + if err = Encrypt(b, encrypted_outname, id.Recipient()); err != nil { t.Fatal(err) } @@ -73,6 +73,73 @@ func TestEncryptionDecryption(t *testing.T) { } } +func TestMultipleIdentities(t *testing.T) { + var ( + identities []age.Identity + recipients []age.Recipient + ) + + for i := 0; i <= 10; i++ { + id, err := age.GenerateX25519Identity() + if err != nil { + t.Fatalf("age broke: %v", err) + } + identities = append(identities, id) + recipients = append(recipients, id.Recipient()) + } + + for _, str := range strings_to_write { + var ( + outname string = generator.GenerateFullPath() + encrypted_outname string = outname + ".age" + b []byte + err error + ) + + t.Run("testing writing "+str, func(t *testing.T) { + if err = os.WriteFile(outname, []byte(str), fs.FileMode(0600)); err != nil { + t.Fatal(err) + } + + if b, err = os.ReadFile(outname); err != nil { + t.Fatal(err) + } + + if err = Encrypt(b, encrypted_outname, recipients...); err != nil { + t.Fatal(err) + } + + // try decrypting with each identity + for _, id := range identities { + if b, err = Decrypt(encrypted_outname, id); err != nil { + t.Fatal(err) + } + if string(b) != str { + t.Fatal(string(b) + " isn't the same as " + str) + } + } + + // then all of them because why not + if b, err = Decrypt(encrypted_outname, identities...); err != nil { + t.Fatal(err) + } + + if string(b) != str { + t.Fatal(string(b) + " isn't the same as " + str) + } + + if err = os.Remove(outname); err != nil { + t.Fatal(err) + } + + if err = os.Remove(encrypted_outname); err != nil { + t.Fatal(err) + } + + }) + } +} + // TestNewIdentity creats a new identity, writes it to file, then re-reads it back from the file. func TestNewIdentity(t *testing.T) { for i := 0; i <= 1000; i++ {