diff --git a/README.md b/README.md index bca3af20..7a3ead09 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ An alternative interoperable Rust implementation is available at [github.com/str ``` Usage: age -r RECIPIENT [-a] [-o OUTPUT] [INPUT] + age --passphrase [-a] [-o OUTPUT] [INPUT] age --decrypt [-i KEY] [-o OUTPUT] [INPUT] Options: @@ -29,6 +30,7 @@ Options: -a, --armor Encrypt to a PEM encoded format. -p, --passphrase Encrypt with a passphrase. -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. -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...") 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 (ignoring "#" prefixed comments and empty lines), or to an SSH key file. 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 ``` +#### 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 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.) ``` -$ cat ~/.ssh/id_ed25519.pub -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz filippo@Bistromath.local -$ age -r "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZDRcvS8PnhXr30WKSKmf7WKKi92ACUa5nW589WukJz" example.jpg > example.jpg.age +$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age $ age -d -i ~/.ssh/id_ed25519 example.jpg.age > example.jpg ``` diff --git a/agessh/agessh.go b/agessh/agessh.go index 7e2caa92..055cf435 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -12,7 +12,7 @@ // keys, and native X25519 keys should be preferred otherwise. // // 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 import ( diff --git a/cmd/age/age.go b/cmd/age/age.go index f28233a6..8a1211fc 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -39,6 +39,7 @@ Options: -a, --armor Encrypt to a PEM encoded format. -p, --passphrase Encrypt with a passphrase. -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. -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...") 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 (ignoring "#" prefixed comments and empty lines), or to an SSH key file. Multiple keys can be provided, and any unused ones will be ignored. @@ -65,6 +69,7 @@ func main() { outFlag string decryptFlag, armorFlag, passFlag bool recipientFlags, identityFlags multiFlag + recipientsFileFlags multiFlag ) flag.BoolVar(&decryptFlag, "d", false, "decrypt the input") @@ -77,6 +82,8 @@ func main() { flag.BoolVar(&armorFlag, "armor", false, "generate an armored file") flag.Var(&recipientFlags, "r", "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, "identity", "identity (can be repeated)") flag.Parse() @@ -99,18 +106,25 @@ func main() { logFatalf("Error: -r/--recipient can't be used with -d/--decrypt.\n" + "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 if len(identityFlags) > 0 { logFatalf("Error: -i/--identity can't be used in encryption mode.\n" + "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" + "Did you forget to specify -r/--recipient or -p/--passphrase?") } if len(recipientFlags) > 0 && passFlag { 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 @@ -158,7 +172,7 @@ func main() { } encryptPass(pass, in, out, armorFlag) default: - encryptKeys(recipientFlags, in, out, armorFlag) + encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag) } } @@ -189,7 +203,7 @@ func passphrasePromptForEncryption() (string, error) { 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 for _, arg := range keys { r, err := parseRecipient(arg) @@ -198,6 +212,20 @@ func encryptKeys(keys []string, in io.Reader, out io.Writer, armor bool) { } 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) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index e58f6688..74b5c1f8 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -8,6 +8,7 @@ package main import ( "bufio" + "encoding/base64" "fmt" "io" "io/ioutil" @@ -16,6 +17,7 @@ import ( "filippo.io/age" "filippo.io/age/agessh" + "golang.org/x/crypto/cryptobyte" "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) } +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) { f, err := os.Open(name) if err != nil { diff --git a/parse.go b/parse.go new file mode 100644 index 00000000..70b36a6f --- /dev/null +++ b/parse.go @@ -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 +} diff --git a/x25519.go b/x25519.go index 5dac27e1..43626943 100644 --- a/x25519.go +++ b/x25519.go @@ -7,7 +7,6 @@ package age import ( - "bufio" "crypto/rand" "crypto/sha256" "errors" @@ -159,40 +158,6 @@ func ParseX25519Identity(s string) (*X25519Identity, error) { 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) { if block.Type != "X25519" { return nil, ErrIncorrectIdentity