1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214
|
package main
import (
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"runtime/debug"
"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.
WARNING: IN 90% OF CASES, YOU DON'T NEED THIS PLUGIN.
This functionality is not built into the age CLI because most applications
should use native keys instead of scripting passphrase-based encryption.
Humans are notoriously bad at remembering and generating strong passphrases.
age uses scrypt to partially mitigate this, which is necessarily very slow.
If a computer will be doing the remembering anyway, you can and should use
native keys instead. There is no need to manage separate public and private
keys, you encrypt directly to the private key:
$ age-keygen -o key.txt
$ age -e -i key.txt file.txt > file.txt.age
$ age -d -i key.txt file.txt.age > file.txt
Likewise, you can store a native identity string in an environment variable
or through your CI secrets manager and use it to encrypt and decrypt files
non-interactively:
$ export AGE_SECRET=$(age-keygen)
$ age -e -i <(echo "$AGE_SECRET") file.txt > file.txt.age
$ age -d -i <(echo "$AGE_SECRET") file.txt.age > file.txt
The age CLI also natively supports passphrase-encrypted identity files, so you
can use that functionality to non-interactively encrypt multiple files such that
you will be able to decrypt them later by entering the same passphrase:
$ age-keygen -pq | age -p -o encrypted-identity.txt
Public key: age1pq1cd[... 1950 more characters ...]
Enter passphrase (leave empty to autogenerate a secure one):
age: using autogenerated passphrase "eternal-erase-keen-suffer-fog-exclude-huge-scorpion-escape-scrub"
$ age -r age1pq1cd[... 1950 more characters ...] file.txt > file.txt.age
$ age -d -i encrypted-identity.txt file.txt.age > file.txt
Enter passphrase for identity file "encrypted-identity.txt":
Finally, when using this plugin care should be taken not to let the password be
persisted in the shell history or leaked to other users on multi-user systems.
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.`
// Version can be set at link time to override debug.BuildInfo.Main.Version when
// building manually without git history. It should look like "v1.2.3".
var Version string
func main() {
flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) }
p, err := plugin.New("batchpass")
if err != nil {
log.Fatal(err)
}
p.RegisterFlags(nil)
versionFlag := flag.Bool("version", false, "print the version")
flag.Parse()
if *versionFlag {
if buildInfo, ok := debug.ReadBuildInfo(); ok && Version == "" {
Version = buildInfo.Main.Version
}
fmt.Println(Version)
return
}
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
}
|