|
| 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 | +} |
0 commit comments