Skip to content

Commit 50a81fd

Browse files
committed
cmd/age-plugin-batchpass: plugin for non-interactive passphrase encryption
Fixes #603 Closes #641 Closes #520 Updates #256 Updates #182 Updates #257 Updates #275 Updates #346 Updates #386 Updates #445 Updates #590 Updates #572
1 parent 44a4fcc commit 50a81fd

File tree

5 files changed

+275
-3
lines changed

5 files changed

+275
-3
lines changed
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"flag"
6+
"fmt"
7+
"io"
8+
"log"
9+
"os"
10+
"strconv"
11+
"strings"
12+
13+
"filippo.io/age"
14+
"filippo.io/age/plugin"
15+
)
16+
17+
const usage = `age-plugin-batchpass is an age plugin that enables non-interactive
18+
passphrase-based encryption and decryption using environment variables.
19+
20+
It is not built into the age CLI because most applications should use
21+
native keys instead of scripting passphrase-based encryption.
22+
23+
Usage:
24+
25+
AGE_PASSPHRASE=password age -e -j batchpass file.txt > file.txt.age
26+
27+
AGE_PASSPHRASE=password age -d -j batchpass file.txt.age > file.txt
28+
29+
Alternatively, you can use AGE_PASSPHRASE_FD to read the passphrase from
30+
a file descriptor. Trailing newlines are stripped from the file contents.
31+
32+
When encrypting, you can set AGE_PASSPHRASE_WORK_FACTOR to adjust the scrypt
33+
work factor (between 1 and 30, default 18). Higher values are more secure
34+
but slower.
35+
36+
When decrypting, you can set AGE_PASSPHRASE_MAX_WORK_FACTOR to limit the
37+
maximum scrypt work factor accepted (between 1 and 30, default 30). This can
38+
be used to avoid very slow decryptions.`
39+
40+
func main() {
41+
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
42+
43+
p, err := plugin.New("batchpass")
44+
if err != nil {
45+
log.Fatal(err)
46+
}
47+
p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) {
48+
if len(data) != 0 {
49+
return nil, fmt.Errorf("batchpass identity does not take any payload")
50+
}
51+
pass, err := passphrase()
52+
if err != nil {
53+
return nil, err
54+
}
55+
r, err := age.NewScryptRecipient(pass)
56+
if err != nil {
57+
return nil, fmt.Errorf("failed to create scrypt recipient: %v", err)
58+
}
59+
if envWorkFactor := os.Getenv("AGE_PASSPHRASE_WORK_FACTOR"); envWorkFactor != "" {
60+
workFactor, err := strconv.Atoi(envWorkFactor)
61+
if err != nil {
62+
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_WORK_FACTOR: %v", err)
63+
}
64+
if workFactor > 30 || workFactor < 1 {
65+
return nil, fmt.Errorf("AGE_PASSPHRASE_WORK_FACTOR must be between 1 and 30")
66+
}
67+
r.SetWorkFactor(workFactor)
68+
}
69+
return r, nil
70+
})
71+
p.HandleIdentity(func(data []byte) (age.Identity, error) {
72+
if len(data) != 0 {
73+
return nil, fmt.Errorf("batchpass identity does not take any payload")
74+
}
75+
pass, err := passphrase()
76+
if err != nil {
77+
return nil, err
78+
}
79+
maxWorkFactor := 0
80+
if envMaxWorkFactor := os.Getenv("AGE_PASSPHRASE_MAX_WORK_FACTOR"); envMaxWorkFactor != "" {
81+
maxWorkFactor, err = strconv.Atoi(envMaxWorkFactor)
82+
if err != nil {
83+
return nil, fmt.Errorf("invalid AGE_PASSPHRASE_MAX_WORK_FACTOR: %v", err)
84+
}
85+
if maxWorkFactor > 30 || maxWorkFactor < 1 {
86+
return nil, fmt.Errorf("AGE_PASSPHRASE_MAX_WORK_FACTOR must be between 1 and 30")
87+
}
88+
}
89+
return &batchpassIdentity{password: pass, maxWorkFactor: maxWorkFactor}, nil
90+
})
91+
os.Exit(p.Main())
92+
}
93+
94+
type batchpassIdentity struct {
95+
password string
96+
maxWorkFactor int
97+
}
98+
99+
func (i *batchpassIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
100+
for _, s := range stanzas {
101+
if s.Type == "scrypt" && len(stanzas) != 1 {
102+
return nil, errors.New("an scrypt recipient must be the only one")
103+
}
104+
}
105+
if len(stanzas) != 1 || stanzas[0].Type != "scrypt" {
106+
// Don't fallback to other identities, this plugin should mostly be used
107+
// in isolation, from the CLI.
108+
return nil, fmt.Errorf("file is not passphrase-encrypted")
109+
}
110+
ii, err := age.NewScryptIdentity(i.password)
111+
if err != nil {
112+
return nil, err
113+
}
114+
if i.maxWorkFactor != 0 {
115+
ii.SetMaxWorkFactor(i.maxWorkFactor)
116+
}
117+
fileKey, err := ii.Unwrap(stanzas)
118+
if errors.Is(err, age.ErrIncorrectIdentity) {
119+
// ScryptIdentity returns ErrIncorrectIdentity to make it possible to
120+
// try multiple passphrases from the API. If a user is invoking this
121+
// plugin, it's safe to say they expect it to be the only mechanism to
122+
// decrypt a passphrase-protected file.
123+
return nil, fmt.Errorf("incorrect passphrase")
124+
}
125+
return fileKey, err
126+
}
127+
128+
func passphrase() (string, error) {
129+
envPASSPHRASE := os.Getenv("AGE_PASSPHRASE")
130+
envFD := os.Getenv("AGE_PASSPHRASE_FD")
131+
if envPASSPHRASE != "" && envFD != "" {
132+
return "", fmt.Errorf("AGE_PASSPHRASE and AGE_PASSPHRASE_FD are mutually exclusive")
133+
}
134+
if envPASSPHRASE == "" && envFD == "" {
135+
return "", fmt.Errorf("either AGE_PASSPHRASE or AGE_PASSPHRASE_FD must be set")
136+
}
137+
138+
if envPASSPHRASE != "" {
139+
return envPASSPHRASE, nil
140+
}
141+
142+
fd, err := strconv.Atoi(envFD)
143+
if err != nil {
144+
return "", fmt.Errorf("invalid AGE_PASSPHRASE_FD: %v", err)
145+
}
146+
f := os.NewFile(uintptr(fd), "AGE_PASSPHRASE_FD")
147+
if f == nil {
148+
return "", fmt.Errorf("failed to open file descriptor %d", fd)
149+
}
150+
defer f.Close()
151+
const maxPassphraseSize = 1024 * 1024 // 1 MiB
152+
b, err := io.ReadAll(io.LimitReader(f, maxPassphraseSize+1))
153+
if err != nil {
154+
return "", fmt.Errorf("failed to read passphrase from fd %d: %v", fd, err)
155+
}
156+
if len(b) > maxPassphraseSize {
157+
return "", fmt.Errorf("passphrase from fd %d is too long", fd)
158+
}
159+
return strings.TrimRight(string(b), "\r\n"), nil
160+
}

cmd/age/age.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,7 @@ func (rejectScryptIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) {
440440
}
441441

442442
func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
443-
identities := []age.Identity{rejectScryptIdentity{}}
444-
443+
var identities []age.Identity
445444
for _, f := range flags {
446445
switch f.Type {
447446
case "i":
@@ -458,7 +457,7 @@ func decryptNotPass(flags identityFlags, in io.Reader, out io.Writer) {
458457
identities = append(identities, id)
459458
}
460459
}
461-
460+
identities = append(identities, rejectScryptIdentity{})
462461
decrypt(identities, in, out)
463462
}
464463

cmd/age/age_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ var buildExtraCommands = sync.OnceValue(func() error {
6060
}
6161
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen")
6262
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq")
63+
cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-batchpass")
6364
cmd.Stdout = os.Stdout
6465
cmd.Stderr = os.Stderr
6566
return cmd.Run()

cmd/age/testdata/batchpass.txt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# encrypt and decrypt with AGE_PASSPHRASE
2+
env AGE_PASSPHRASE_WORK_FACTOR=5
3+
env AGE_PASSPHRASE=password
4+
age -e -j batchpass -o test.age input
5+
age -d -j batchpass test.age
6+
cmp stdout input
7+
8+
# decrypt with AGE_PASSPHRASE_MAX_WORK_FACTOR
9+
env AGE_PASSPHRASE_MAX_WORK_FACTOR=10
10+
age -d -j batchpass test.age
11+
cmp stdout input
12+
13+
# AGE_PASSPHRASE_MAX_WORK_FACTOR lower than work factor
14+
env AGE_PASSPHRASE_MAX_WORK_FACTOR=3
15+
! age -d -j batchpass test.age
16+
stderr 'work factor'
17+
env AGE_PASSPHRASE_MAX_WORK_FACTOR=
18+
19+
# error: both AGE_PASSPHRASE and AGE_PASSPHRASE_FD set
20+
env AGE_PASSPHRASE=password
21+
env AGE_PASSPHRASE_FD=3
22+
! age -e -j batchpass -a input
23+
stderr 'mutually exclusive'
24+
25+
# error: neither AGE_PASSPHRASE nor AGE_PASSPHRASE_FD set
26+
env AGE_PASSPHRASE=
27+
env AGE_PASSPHRASE_FD=
28+
! age -e -j batchpass -a test.age
29+
stderr 'must be set'
30+
31+
# error: incorrect passphrase
32+
env AGE_PASSPHRASE=wrongpassword
33+
! age -d -j batchpass test.age
34+
stderr 'incorrect passphrase'
35+
36+
# error: encrypting to other recipients along with passphrase
37+
env AGE_PASSPHRASE=password
38+
! age -e -j batchpass -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -a input
39+
stderr 'incompatible recipients'
40+
! age -e -r age1gc2zwslg9j25pg9e9ld7623aewc2gjzquzgq90m75r75myqdt5cq3yudvh -j batchpass -a input
41+
stderr 'incompatible recipients'
42+
43+
# decrypt with native scrypt
44+
[!linux] [!darwin] skip # no pty support
45+
[darwin] [go1.20] skip # https://go.dev/issue/61779
46+
ttyin terminal
47+
age -d test.age
48+
cmp stdout input
49+
50+
-- terminal --
51+
password
52+
password
53+
-- input --
54+
test

doc/age-plugin-batchpass.1.ronn

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1)
2+
==================================================================================
3+
4+
## SYNOPSIS
5+
6+
`age` `-e` `-j` `batchpass`<br>
7+
`age` `-d` `-j` `batchpass`
8+
9+
## DESCRIPTION
10+
11+
`age-plugin-batchpass` is an age(1) plugin that enables non-interactive
12+
passphrase-based encryption and decryption using environment variables.
13+
14+
It is not built into the age CLI because most applications should use
15+
native keys instead of scripting passphrase-based encryption.
16+
17+
## ENVIRONMENT
18+
19+
* `AGE_PASSPHRASE`:
20+
The passphrase to use for encryption or decryption.
21+
Mutually exclusive with `AGE_PASSPHRASE_FD`.
22+
23+
* `AGE_PASSPHRASE_FD`:
24+
A file descriptor number to read the passphrase from.
25+
Trailing newlines are stripped from the file contents.
26+
Mutually exclusive with `AGE_PASSPHRASE`.
27+
28+
* `AGE_PASSPHRASE_WORK_FACTOR`:
29+
The scrypt work factor to use when encrypting.
30+
Must be between 1 and 30. Default is 18.
31+
Higher values are more secure but slower.
32+
33+
* `AGE_PASSPHRASE_MAX_WORK_FACTOR`:
34+
The maximum scrypt work factor to accept when decrypting.
35+
Must be between 1 and 30. Default is 30.
36+
Can be used to avoid very slow decryptions.
37+
38+
## EXAMPLES
39+
40+
Encrypt a file with a passphrase:
41+
42+
$ AGE_PASSPHRASE=secret age -e -j batchpass file.txt > file.txt.age
43+
44+
Decrypt a file with a passphrase:
45+
46+
$ AGE_PASSPHRASE=secret age -d -j batchpass file.txt.age > file.txt
47+
48+
Read the passphrase from a file descriptor:
49+
50+
$ AGE_PASSPHRASE_FD=3 age -e -j batchpass file.txt 3< passphrase.txt > file.txt.age
51+
52+
## SEE ALSO
53+
54+
age(1)
55+
56+
## AUTHORS
57+
58+
Filippo Valsorda <age@filippo.io>

0 commit comments

Comments
 (0)