Skip to content

Commit

Permalink
age,cmd/age: add ParseRecipients and -R for recipient files
Browse files Browse the repository at this point in the history
Fixes #84
Fixes #66
Closes #165
Closes #158
Closes #115
Closes #64
Closes #43
Closes #20
  • Loading branch information
FiloSottile committed Jan 3, 2021
1 parent 7ab2008 commit f8507c1
Show file tree
Hide file tree
Showing 6 changed files with 199 additions and 42 deletions.
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ 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:
-o, --output OUTPUT Write the result to the file at path OUTPUT.
-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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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 [email protected]
$ 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
```

Expand Down
2 changes: 1 addition & 1 deletion agessh/agessh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
34 changes: 31 additions & 3 deletions cmd/age/age.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -158,7 +172,7 @@ func main() {
}
encryptPass(pass, in, out, armorFlag)
default:
encryptKeys(recipientFlags, in, out, armorFlag)
encryptKeys(recipientFlags, recipientsFileFlags, in, out, armorFlag)
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down
62 changes: 62 additions & 0 deletions cmd/age/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package main

import (
"bufio"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
Expand All @@ -16,6 +17,7 @@ import (

"filippo.io/age"
"filippo.io/age/agessh"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)

Expand All @@ -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 {
Expand Down
86 changes: 86 additions & 0 deletions parse.go
Original file line number Diff line number Diff line change
@@ -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
}
35 changes: 0 additions & 35 deletions x25519.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
package age

import (
"bufio"
"crypto/rand"
"crypto/sha256"
"errors"
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit f8507c1

Please sign in to comment.