age,cmd/age: add ParseRecipients and -R for recipient files

Fixes #84
Fixes #66
Closes #165
Closes #158
Closes #115
Closes #64
Closes #43
Closes #20
This commit is contained in:
Filippo Valsorda
2021-01-02 14:42:31 +01:00
committed by Filippo Valsorda
parent 7ab2008136
commit f8507c1cac
6 changed files with 199 additions and 42 deletions

View File

@@ -22,6 +22,7 @@ An alternative interoperable Rust implementation is available at [github.com/str
``` ```
Usage: Usage:
age -r RECIPIENT [-a] [-o OUTPUT] [INPUT] age -r RECIPIENT [-a] [-o OUTPUT] [INPUT]
age --passphrase [-a] [-o OUTPUT] [INPUT]
age --decrypt [-i KEY] [-o OUTPUT] [INPUT] age --decrypt [-i KEY] [-o OUTPUT] [INPUT]
Options: Options:
@@ -29,6 +30,7 @@ Options:
-a, --armor Encrypt to a PEM encoded format. -a, --armor Encrypt to a PEM encoded format.
-p, --passphrase Encrypt with a passphrase. -p, --passphrase Encrypt with a passphrase.
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-d, --decrypt Decrypt the input to the output. -d, --decrypt Decrypt the input to the output.
-i, --identity KEY Use the private key file at path KEY. Can be repeated. -i, --identity KEY Use the private key file at path KEY. Can be repeated.
@@ -37,6 +39,9 @@ INPUT defaults to standard input, and OUTPUT defaults to standard output.
RECIPIENT can be an age public key, as generated by age-keygen, ("age1...") RECIPIENT can be an age public key, as generated by age-keygen, ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA..."). or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments.
KEY is a path to a file with age secret keys, one per line KEY is a path to a file with age secret keys, one per line
(ignoring "#" prefixed comments and empty lines), or to an SSH key file. (ignoring "#" prefixed comments and empty lines), or to an SSH key file.
Multiple keys can be provided, and any unused ones will be ignored. Multiple keys can be provided, and any unused ones will be ignored.
@@ -51,6 +56,19 @@ $ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sf
-r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg -r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg
``` ```
#### Recipient files
Multiple recipients can also be listed one per line in one or more files passed with the `-R/--recipients-file` flag.
```
$ cat recipients.txt
# Alice
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
# Bob
age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg
$ age -R recipients.txt example.jpg > example.jpg.age
```
### Passphrases ### Passphrases
Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time. Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time.
@@ -68,9 +86,7 @@ Enter passphrase:
As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.) As a convenience feature, age also supports encrypting to `ssh-rsa` and `ssh-ed25519` SSH public keys, and decrypting with the respective private key file. (`ssh-agent` is not supported.)
``` ```
$ cat ~/.ssh/id_ed25519.pub $ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz filippo@Bistromath.local
$ age -r "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz" example.jpg > example.jpg.age
$ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg $ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg
``` ```

View File

@@ -12,7 +12,7 @@
// keys, and native X25519 keys should be preferred otherwise. // keys, and native X25519 keys should be preferred otherwise.
// //
// Note that these recipient types are not anonymous: the encrypted message will // Note that these recipient types are not anonymous: the encrypted message will
// include a short 32-bit ID of the public key, // include a short 32-bit ID of the public key.
package agessh package agessh
import ( import (

View File

@@ -39,6 +39,7 @@ Options:
-a, --armor Encrypt to a PEM encoded format. -a, --armor Encrypt to a PEM encoded format.
-p, --passphrase Encrypt with a passphrase. -p, --passphrase Encrypt with a passphrase.
-r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated. -r, --recipient RECIPIENT Encrypt to the specified RECIPIENT. Can be repeated.
-R, --recipients-file PATH Encrypt to recipients listed at PATH. Can be repeated.
-d, --decrypt Decrypt the input to the output. -d, --decrypt Decrypt the input to the output.
-i, --identity KEY Use the private key file at path KEY. Can be repeated. -i, --identity KEY Use the private key file at path KEY. Can be repeated.
@@ -47,6 +48,9 @@ INPUT defaults to standard input, and OUTPUT defaults to standard output.
RECIPIENT can be an age public key, as generated by age-keygen, ("age1...") RECIPIENT can be an age public key, as generated by age-keygen, ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA..."). or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
Recipient files contain one or more recipients, one per line. Empty lines
and lines starting with "#" are ignored as comments.
KEY is a path to a file with age secret keys, one per line KEY is a path to a file with age secret keys, one per line
(ignoring "#" prefixed comments and empty lines), or to an SSH key file. (ignoring "#" prefixed comments and empty lines), or to an SSH key file.
Multiple keys can be provided, and any unused ones will be ignored. Multiple keys can be provided, and any unused ones will be ignored.
@@ -65,6 +69,7 @@ func main() {
outFlag string outFlag string
decryptFlag, armorFlag, passFlag bool decryptFlag, armorFlag, passFlag bool
recipientFlags, identityFlags multiFlag recipientFlags, identityFlags multiFlag
recipientsFileFlags multiFlag
) )
flag.BoolVar(&decryptFlag, "d", false, "decrypt the input") flag.BoolVar(&decryptFlag, "d", false, "decrypt the input")
@@ -77,6 +82,8 @@ func main() {
flag.BoolVar(&armorFlag, "armor", false, "generate an armored file") flag.BoolVar(&armorFlag, "armor", false, "generate an armored file")
flag.Var(&recipientFlags, "r", "recipient (can be repeated)") flag.Var(&recipientFlags, "r", "recipient (can be repeated)")
flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)") flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)")
flag.Var(&recipientsFileFlags, "R", "recipients file (can be repeated)")
flag.Var(&recipientsFileFlags, "recipients-file", "recipients file (can be repeated)")
flag.Var(&identityFlags, "i", "identity (can be repeated)") flag.Var(&identityFlags, "i", "identity (can be repeated)")
flag.Var(&identityFlags, "identity", "identity (can be repeated)") flag.Var(&identityFlags, "identity", "identity (can be repeated)")
flag.Parse() flag.Parse()
@@ -99,18 +106,25 @@ func main() {
logFatalf("Error: -r/--recipient can't be used with -d/--decrypt.\n" + logFatalf("Error: -r/--recipient can't be used with -d/--decrypt.\n" +
"Did you mean to use -i/--identity to specify a private key?") "Did you mean to use -i/--identity to specify a private key?")
} }
if len(recipientsFileFlags) > 0 {
logFatalf("Error: -R/--recipients-file can't be used with -d/--decrypt.\n" +
"Did you mean to use -i/--identity to specify a private key?")
}
default: // encrypt default: // encrypt
if len(identityFlags) > 0 { if len(identityFlags) > 0 {
logFatalf("Error: -i/--identity can't be used in encryption mode.\n" + logFatalf("Error: -i/--identity can't be used in encryption mode.\n" +
"Did you forget to specify -d/--decrypt?") "Did you forget to specify -d/--decrypt?")
} }
if len(recipientFlags) == 0 && !passFlag { if len(recipientFlags) == 0 && len(recipientsFileFlags) == 0 && !passFlag {
logFatalf("Error: missing recipients.\n" + logFatalf("Error: missing recipients.\n" +
"Did you forget to specify -r/--recipient or -p/--passphrase?") "Did you forget to specify -r/--recipient or -p/--passphrase?")
} }
if len(recipientFlags) > 0 && passFlag { if len(recipientFlags) > 0 && passFlag {
logFatalf("Error: -p/--passphrase can't be combined with -r/--recipient.") logFatalf("Error: -p/--passphrase can't be combined with -r/--recipient.")
} }
if len(recipientsFileFlags) > 0 && passFlag {
logFatalf("Error: -p/--passphrase can't be combined with -R/--recipients-file.")
}
} }
var in, out io.ReadWriter = os.Stdin, os.Stdout var in, out io.ReadWriter = os.Stdin, os.Stdout
@@ -158,7 +172,7 @@ func main() {
} }
encryptPass(pass, in, out, armorFlag) encryptPass(pass, in, out, armorFlag)
default: default:
encryptKeys(recipientFlags, in, out, armorFlag) encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag)
} }
} }
@@ -189,7 +203,7 @@ func passphrasePromptForEncryption() (string, error) {
return p, nil return p, nil
} }
func encryptKeys(keys []string, in io.Reader, out io.Writer, armor bool) { func encryptKeys(keys, files []string, in io.Reader, out io.Writer, armor bool) {
var recipients []age.Recipient var recipients []age.Recipient
for _, arg := range keys { for _, arg := range keys {
r, err := parseRecipient(arg) r, err := parseRecipient(arg)
@@ -198,6 +212,20 @@ func encryptKeys(keys []string, in io.Reader, out io.Writer, armor bool) {
} }
recipients = append(recipients, r) recipients = append(recipients, r)
} }
for _, name := range files {
f, err := os.Open(name)
if err != nil {
logFatalf("Error: failed to open recipient file: %v", err)
}
recs, err := parseRecipients(f, func(format string, a ...interface{}) {
a = append([]interface{}{name}, a...)
_log.Printf("Warning: recipients file %q: "+format, a...)
})
if err != nil {
logFatalf("Error: failed to parse recipient file %q: %v", name, err)
}
recipients = append(recipients, recs...)
}
encrypt(recipients, in, out, armor) encrypt(recipients, in, out, armor)
} }

View File

@@ -8,6 +8,7 @@ package main
import ( import (
"bufio" "bufio"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"io/ioutil" "io/ioutil"
@@ -16,6 +17,7 @@ import (
"filippo.io/age" "filippo.io/age"
"filippo.io/age/agessh" "filippo.io/age/agessh"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
@@ -30,6 +32,66 @@ func parseRecipient(arg string) (age.Recipient, error) {
return nil, fmt.Errorf("unknown recipient type: %q", arg) return nil, fmt.Errorf("unknown recipient type: %q", arg)
} }
func parseRecipients(f io.Reader, warnf func(string, ...interface{})) ([]age.Recipient, error) {
const recipientFileSizeLimit = 16 << 20 // 16 MiB
const lineLengthLimit = 8 << 10 // 8 KiB, same as sshd(8)
var recs []age.Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
if len(line) > lineLengthLimit {
return nil, fmt.Errorf("line %d is too long", n)
}
r, err := parseRecipient(line)
if err != nil {
if t, ok := sshKeyType(line); ok {
// Skip unsupported but valid SSH public keys with a warning.
warnf("ignoring unsupported SSH key of type %q at line %d", t, n)
continue
}
// Hide the error since it might unintentionally leak the contents
// of confidential files.
return nil, fmt.Errorf("malformed recipient at line %d", n)
}
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err)
}
if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found")
}
return recs, nil
}
func sshKeyType(s string) (string, bool) {
// TODO: also ignore options? And maybe support multiple spaces and tabs as
// field separators like OpenSSH?
fields := strings.Split(s, " ")
if len(fields) < 2 {
return "", false
}
key, err := base64.StdEncoding.DecodeString(fields[1])
if err != nil {
return "", false
}
k := cryptobyte.String(key)
var typeLen uint32
var typeBytes []byte
if !k.ReadUint32(&typeLen) || !k.ReadBytes(&typeBytes, int(typeLen)) {
return "", false
}
if t := fields[0]; t == string(typeBytes) {
return t, true
}
return "", false
}
func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentitiesFile(name string) ([]age.Identity, error) {
f, err := os.Open(name) f, err := os.Open(name)
if err != nil { if err != nil {

86
parse.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright 2021 Google LLC
//
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file or at
// https://developers.google.com/open-source/licenses/bsd
package age
import (
"bufio"
"fmt"
"io"
"strings"
)
// ParseIdentities parses a file with one or more private key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the
// average application.
//
// Currently, all returned values are of type *X25519Identity, but different
// types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
i, err := ParseX25519Identity(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
ids = append(ids, i)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found")
}
return ids, nil
}
// ParseRecipients parses a file with one or more public key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the recipients files accepted by the CLI, except
// the CLI also accepts SSH recipients, which are not recommended for the
// average application.
//
// Currently, all returned values are of type *X25519Recipient, but different
// types might be returned in the future.
func ParseRecipients(f io.Reader) ([]Recipient, error) {
const recipientFileSizeLimit = 1 << 24 // 16 MiB
var recs []Recipient
scanner := bufio.NewScanner(io.LimitReader(f, recipientFileSizeLimit))
var n int
for scanner.Scan() {
n++
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
r, err := ParseX25519Recipient(line)
if err != nil {
// Hide the error since it might unintentionally leak the contents
// of confidential files.
return nil, fmt.Errorf("malformed recipient at line %d", n)
}
recs = append(recs, r)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read recipients file: %v", err)
}
if len(recs) == 0 {
return nil, fmt.Errorf("no recipients found")
}
return recs, nil
}

View File

@@ -7,7 +7,6 @@
package age package age
import ( import (
"bufio"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"errors" "errors"
@@ -159,40 +158,6 @@ func ParseX25519Identity(s string) (*X25519Identity, error) {
return r, nil return r, nil
} }
// ParseIdentities parses a file with one or more private key encodings, one per
// line. Empty lines and lines starting with "#" are ignored.
//
// This is the same syntax as the private key files accepted by the CLI, except
// the CLI also accepts SSH private keys, which are not recommended for the
// average application.
//
// Currently, all returned values are of type X25519Identity, but different
// types might be returned in the future.
func ParseIdentities(f io.Reader) ([]Identity, error) {
const privateKeySizeLimit = 1 << 24 // 16 MiB
var ids []Identity
scanner := bufio.NewScanner(io.LimitReader(f, privateKeySizeLimit))
var n int
for scanner.Scan() {
line := scanner.Text()
if strings.HasPrefix(line, "#") || line == "" {
continue
}
i, err := ParseX25519Identity(line)
if err != nil {
return nil, fmt.Errorf("error at line %d: %v", n, err)
}
ids = append(ids, i)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("failed to read secret keys file: %v", err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("no secret keys found")
}
return ids, nil
}
func (i *X25519Identity) Unwrap(block *Stanza) ([]byte, error) { func (i *X25519Identity) Unwrap(block *Stanza) ([]byte, error) {
if block.Type != "X25519" { if block.Type != "X25519" {
return nil, ErrIncorrectIdentity return nil, ErrIncorrectIdentity