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