diff --git a/cmd/age-plugin-batchpass/plugin-batchpass.go b/cmd/age-plugin-batchpass/plugin-batchpass.go new file mode 100644 index 0000000..3974d77 --- /dev/null +++ b/cmd/age-plugin-batchpass/plugin-batchpass.go @@ -0,0 +1,160 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "strconv" + "strings" + + "filippo.io/age" + "filippo.io/age/plugin" +) + +const usage = `age-plugin-batchpass is an age plugin that enables non-interactive +passphrase-based encryption and decryption using environment variables. + +It is not built into the age CLI because most applications should use +native keys instead of scripting passphrase-based encryption. + +Usage: + + AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age + + AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt + +Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from +a file descriptor. Trailing newlines are stripped from the file contents. + +When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt +work factor (between 1 and 30, default 18). Higher values are more secure +but slower. + +When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to limit the +maximum scrypt work factor accepted (between 1 and 30, default 30). This can +be used to avoid very slow decryptions.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + p, err := plugin.New("batchpass") + if err != nil { + log.Fatal(err) + } + p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { + if len(data) != 0 { + return nil, fmt.Errorf("batchpass identity does not take any payload") + } + pass, err := passphrase() + if err != nil { + return nil, err + } + r, err := age.NewScryptRecipient(pass) + if err != nil { + return nil, fmt.Errorf("failed to create scrypt recipient: %v", err) + } + if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" { + workFactor, err := strconv.Atoi(envWorkFactor) + if err != nil { + return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err) + } + if workFactor > 30 || workFactor < 1 { + return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30") + } + r.SetWorkFactor(workFactor) + } + return r, nil + }) + p.HandleIdentity(func(data []byte) (age.Identity, error) { + if len(data) != 0 { + return nil, fmt.Errorf("batchpass identity does not take any payload") + } + pass, err := passphrase() + if err != nil { + return nil, err + } + maxWorkFactor := 0 + if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" { + maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor) + if err != nil { + return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err) + } + if maxWorkFactor > 30 || maxWorkFactor < 1 { + return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30") + } + } + return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil + }) + os.Exit(p.Main()) +} + +type batchpassIdentity struct { + password string + maxWorkFactor int +} + +func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + for _, s := range stanzas { + if s.Type == "scrypt" && len(stanzas) != 1 { + return nil, errors.New("an scrypt recipient must be the only one") + } + } + if len(stanzas) != 1 || stanzas[0].Type != "scrypt" { + // Don't fallback to other identities, this plugin should mostly be used + // in isolation, from the CLI. + return nil, fmt.Errorf("file is not passphrase-encrypted") + } + ii, err := age.NewScryptIdentity(i.password) + if err != nil { + return nil, err + } + if i.maxWorkFactor != 0 { + ii.SetMaxWorkFactor(i.maxWorkFactor) + } + fileKey, err := ii.Unwrap(stanzas) + if errors.Is(err, age.ErrIncorrectIdentity) { + // ScryptIdentity returns ErrIncorrectIdentity to make it possible to + // try multiple passphrases from the API. If a user is invoking this + // plugin, it's safe to say they expect it to be the only mechanism to + // decrypt a passphrase-protected file. + return nil, fmt.Errorf("incorrect passphrase") + } + return fileKey, err +} + +func passphrase() (string, error) { + envPASSPHRASE := os.Getenv("AGE_PASSPHRASE") + envFD := os.Getenv("AGE_PASSPHRASE_FD") + if envPASSPHRASE != "" && envFD != "" { + return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive") + } + if envPASSPHRASE == "" && envFD == "" { + return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set") + } + + if envPASSPHRASE != "" { + return envPASSPHRASE, nil + } + + fd, err := strconv.Atoi(envFD) + if err != nil { + return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err) + } + f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD") + if f == nil { + return "", fmt.Errorf("failed to open file descriptor %d", fd) + } + defer f.Close() + const maxPassphraseSize = 1024 * 1024 // 1 MiB + b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1)) + if err != nil { + return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err) + } + if len(b) > maxPassphraseSize { + return "", fmt.Errorf("passphrase from fd %d is too long", fd) + } + return strings.TrimRight(string(b), "\r\n"), nil +} diff --git a/cmd/age/age.go b/cmd/age/age.go index 2ff0a8c..f6661c1 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -440,8 +440,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { } func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { - identities := []age.Identity{rejectScryptIdentity{}} - + var identities []age.Identity for _, f := range flags { switch f.Type { case "i": @@ -458,7 +457,7 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) { identities = append(identities, id) } } - + identities = append(identities, rejectScryptIdentity{}) decrypt(identities, in, out) } diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 5213384..522a638 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -60,6 +60,7 @@ var buildExtraCommands = sync.OnceValue(func() error { } cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen") cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq") + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-batchpass") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() diff --git a/cmd/age/testdata/batchpass.txt b/cmd/age/testdata/batchpass.txt new file mode 100644 index 0000000..a37361c --- /dev/null +++ b/cmd/age/testdata/batchpass.txt @@ -0,0 +1,54 @@ +# encrypt and decrypt with AGE_PASSPHRASE +env AGE_PASSPHRASE_WORK_FACTOR=5 +env AGE_PASSPHRASE=password +age -e -j batchpass -o test.age input +age -d -j batchpass test.age +cmp stdout input + +# decrypt with AGE_PASSPHRASE_MAX_WORK_FACTOR +env AGE_PASSPHRASE_MAX_WORK_FACTOR=10 +age -d -j batchpass test.age +cmp stdout input + +# AGE_PASSPHRASE_MAX_WORK_FACTOR lower than work factor +env AGE_PASSPHRASE_MAX_WORK_FACTOR=3 +! age -d -j batchpass test.age +stderr 'work factor' +env AGE_PASSPHRASE_MAX_WORK_FACTOR= + +# error: both AGE_PASSPHRASE and AGE_PASSPHRASE_FD set +env AGE_PASSPHRASE=password +env AGE_PASSPHRASE_FD=3 +! age -e -j batchpass -a input +stderr 'mutually exclusive' + +# error: neither AGE_PASSPHRASE nor AGE_PASSPHRASE_FD set +env AGE_PASSPHRASE= +env AGE_PASSPHRASE_FD= +! age -e -j batchpass -a test.age +stderr 'must be set' + +# error: incorrect passphrase +env AGE_PASSPHRASE=wrongpassword +! age -d -j batchpass test.age +stderr 'incorrect passphrase' + +# error: encrypting to other recipients along with passphrase +env AGE_PASSPHRASE=password +! age -e -j batchpass -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -a input +stderr 'incompatible recipients' +! age -e -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -j batchpass -a input +stderr 'incompatible recipients' + +# decrypt with native scrypt +[!linux] [!darwin] skip # no pty support +[darwin] [go1.20] skip # https://go.dev/issue/61779 +ttyin terminal +age -d test.age +cmp stdout input + +-- terminal -- +password +password +-- input -- +test diff --git a/doc/age-plugin-batchpass.1.ronn b/doc/age-plugin-batchpass.1.ronn new file mode 100644 index 0000000..335d6ca --- /dev/null +++ b/doc/age-plugin-batchpass.1.ronn @@ -0,0 +1,58 @@ +age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1) +================================================================================== + +## SYNOPSIS + +`age` `-e` `-j` `batchpass`
+`age` `-d` `-j` `batchpass` + +## DESCRIPTION + +`age-plugin-batchpass` is an age(1) plugin that enables non-interactive +passphrase-based encryption and decryption using environment variables. + +It is not built into the age CLI because most applications should use +native keys instead of scripting passphrase-based encryption. + +## ENVIRONMENT + +* `AGE_PASSPHRASE`: + The passphrase to use for encryption or decryption. + Mutually exclusive with `AGE_PASSPHRASE_FD`. + +* `AGE_PASSPHRASE_FD`: + A file descriptor number to read the passphrase from. + Trailing newlines are stripped from the file contents. + Mutually exclusive with `AGE_PASSPHRASE`. + +* `AGE_PASSPHRASE_WORK_FACTOR`: + The scrypt work factor to use when encrypting. + Must be between 1 and 30. Default is 18. + Higher values are more secure but slower. + +* `AGE_PASSPHRASE_MAX_WORK_FACTOR`: + The maximum scrypt work factor to accept when decrypting. + Must be between 1 and 30. Default is 30. + Can be used to avoid very slow decryptions. + +## EXAMPLES + +Encrypt a file with a passphrase: + + $ AGE_PASSPHRASE=secret age -e -j batchpass file.txt > file.txt.age + +Decrypt a file with a passphrase: + + $ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age > file.txt + +Read the passphrase from a file descriptor: + + $ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3< passphrase.txt > file.txt.age + +## SEE ALSO + +age(1) + +## AUTHORS + +Filippo Valsorda