mirror of
https://github.com/FiloSottile/age.git
synced 2026-03-11 08:55:41 +00:00
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
This commit is contained in:
parent
44a4fcc27b
commit
50a81fd5a9
5 changed files with 275 additions and 3 deletions
160
cmd/age-plugin-batchpass/plugin-batchpass.go
Normal file
160
cmd/age-plugin-batchpass/plugin-batchpass.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
54
cmd/age/testdata/batchpass.txt
vendored
Normal file
54
cmd/age/testdata/batchpass.txt
vendored
Normal file
|
|
@ -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
|
||||
58
doc/age-plugin-batchpass.1.ronn
Normal file
58
doc/age-plugin-batchpass.1.ronn
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
age-plugin-batchpass(1) -- non-interactive passphrase encryption plugin for age(1)
|
||||
==================================================================================
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
`age` `-e` `-j` `batchpass`<br>
|
||||
`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 <age@filippo.io>
|
||||
Loading…
Reference in a new issue