cmd/age: add support for encrypted identity files

Updates #252
Closes #132
This commit is contained in:
Filippo Valsorda
2021-06-07 23:43:01 +02:00
committed by Filippo Valsorda
parent fa5b575ceb
commit fb97277f8d
8 changed files with 199 additions and 45 deletions

View File

@@ -26,20 +26,22 @@ For the full documentation, read [the age(1) man page](https://htmlpreview.githu
```
Usage:
age (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
age --passphrase [--armor] [-o OUTPUT] [INPUT]
age [--encrypt] (-r RECIPIENT | -R PATH)... [--armor] [-o OUTPUT] [INPUT]
age [--encrypt] --passphrase [--armor] [-o OUTPUT] [INPUT]
age --decrypt [-i PATH]... [-o OUTPUT] [INPUT]
Options:
-e, --encrypt Encrypt the input to the output. Default if omitted.
-d, --decrypt Decrypt the input to the output.
-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 PATH Use the identity file at PATH. Can be repeated.
INPUT defaults to standard input, and OUTPUT defaults to standard output.
If OUTPUT exists, it will be overwritten.
RECIPIENT can be an age public key generated by age-keygen ("age1...")
or an SSH public key ("ssh-ed25519 AAAA...", "ssh-rsa AAAA...").
@@ -50,8 +52,12 @@ read recipients from standard input.
Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Multiple key files can be provided, and any unused ones
ignored as comments. Passphrase encrypted age files can be used as
identity files. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.
When --encrypt is specified explicitly, -i can also be used to encrypt to an
identity file symmetrically, instead or in addition to normal recipients.
```
### Multiple recipients
@@ -90,6 +96,24 @@ $ age -d secrets.txt.age > secrets.txt
Enter passphrase:
```
### Passphrase-protected key files
If an identity file passed to `-i` is a passphrase encrypted age file, it will be automatically decrypted.
```
$ age-keygen | age -p > key.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "hip-roast-boring-snake-mention-east-wasp-honey-input-actress".
$ age -r age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 secrets.txt > secrets.txt.age
$ age -d -i key.age secrets.txt.age > secrets.txt
Enter passphrase for identity file "key.age":
```
Passphrase-protected identity files are not necessary for most use cases, where access to the encrypted identity file implies access to the whole system. However, they can be useful if the identity file is stored remotely.
### SSH keys
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.)

View File

@@ -192,7 +192,7 @@ func ParseRecipient(s string) (age.Recipient, error) {
func ed25519PublicKeyToCurve25519(pk ed25519.PublicKey) ([]byte, error) {
// See https://blog.filippo.io/using-ed25519-keys-for-encryption and
// https://pkg.go.dev/filippo.io/edwards25519#Point.BytesMontgomery.
p, err := (&edwards25519.Point{}).SetBytes(pk)
p, err := new(edwards25519.Point).SetBytes(pk)
if err != nil {
return nil, err
}

View File

@@ -24,6 +24,7 @@ import (
// pass the result to NewEd25519Identity or NewRSAIdentity.
type EncryptedSSHIdentity struct {
pubKey ssh.PublicKey
recipient age.Recipient
pemBytes []byte
passphrase func() ([]byte, error)
@@ -41,22 +42,34 @@ type EncryptedSSHIdentity struct {
// passphrase is a callback that will be invoked by Unwrap when the passphrase
// is necessary.
func NewEncryptedSSHIdentity(pubKey ssh.PublicKey, pemBytes []byte, passphrase func() ([]byte, error)) (*EncryptedSSHIdentity, error) {
switch t := pubKey.Type(); t {
case "ssh-ed25519", "ssh-rsa":
default:
return nil, fmt.Errorf("unsupported SSH key type: %v", t)
}
return &EncryptedSSHIdentity{
i := &EncryptedSSHIdentity{
pubKey: pubKey,
pemBytes: pemBytes,
passphrase: passphrase,
}, nil
}
switch t := pubKey.Type(); t {
case "ssh-ed25519":
r, err := NewEd25519Recipient(pubKey)
if err != nil {
return nil, err
}
i.recipient = r
case "ssh-rsa":
r, err := NewRSARecipient(pubKey)
if err != nil {
return nil, err
}
i.recipient = r
default:
return nil, fmt.Errorf("unsupported SSH key type: %v", t)
}
return i, nil
}
var _ age.Identity = &EncryptedSSHIdentity{}
func (i *EncryptedSSHIdentity) Recipient() (age.Recipient, error) {
return ParseRecipient(string(ssh.MarshalAuthorizedKey(i.pubKey)))
func (i *EncryptedSSHIdentity) Recipient() age.Recipient {
return i.recipient
}
// Unwrap implements age.Identity. If the private key is still encrypted, and

View File

@@ -59,7 +59,8 @@ read recipients from standard input.
Identity files contain one or more secret keys ("AGE-SECRET-KEY-1..."),
one per line, or an SSH key. Empty lines and lines starting with "#" are
ignored as comments. Multiple key files can be provided, and any unused ones
ignored as comments. Passphrase encrypted age files can be used as
identity files. Multiple key files can be provided, and any unused ones
will be ignored. "-" may be used to read identities from standard input.
When --encrypt is specified explicitly, -i can also be used to encrypt to an
@@ -270,13 +271,11 @@ func encryptKeys(keys, files, identities []string, in io.Reader, out io.Writer,
if err != nil {
logFatalf("Error reading %q: %v", name, err)
}
for _, id := range ids {
r, err := identityToRecipient(id)
if err != nil {
logFatalf("Internal error processing %q: %v", name, err)
}
recipients = append(recipients, r)
r, err := identitiesToRecipients(ids)
if err != nil {
logFatalf("Internal error processing %q: %v", name, err)
}
recipients = append(recipients, r...)
}
encrypt(recipients, in, out, armor)
}
@@ -350,18 +349,29 @@ func passphrasePrompt() (string, error) {
return string(pass), nil
}
func identityToRecipient(id age.Identity) (age.Recipient, error) {
switch id := id.(type) {
case *age.X25519Identity:
return id.Recipient(), nil
case *agessh.RSAIdentity:
return id.Recipient(), nil
case *agessh.Ed25519Identity:
return id.Recipient(), nil
case *agessh.EncryptedSSHIdentity:
return id.Recipient()
func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) {
var recipients []age.Recipient
for _, id := range ids {
switch id := id.(type) {
case *age.X25519Identity:
recipients = append(recipients, id.Recipient())
case *agessh.RSAIdentity:
recipients = append(recipients, id.Recipient())
case *agessh.Ed25519Identity:
recipients = append(recipients, id.Recipient())
case *agessh.EncryptedSSHIdentity:
recipients = append(recipients, id.Recipient())
case *EncryptedIdentity:
r, err := id.Recipients()
if err != nil {
return nil, err
}
recipients = append(recipients, r...)
default:
return nil, fmt.Errorf("unexpected identity type: %T", id)
}
}
return nil, fmt.Errorf("unexpected identity type: %T", id)
return recipients, nil
}
type lazyOpener struct {

View File

@@ -63,14 +63,14 @@ func TestVectors(t *testing.T) {
if err == nil {
t.Fatal("expected Decrypt failure")
}
if e := (&age.NoIdentityMatchError{}); errors.As(err, &e) {
if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
t.Errorf("got ErrIncorrectIdentity, expected more specific error")
}
} else if expectNoMatch {
if err == nil {
t.Fatal("expected Decrypt failure")
}
if e := (&age.NoIdentityMatchError{}); !errors.As(err, &e) {
if e := new(age.NoIdentityMatchError); !errors.As(err, &e) {
t.Errorf("expected ErrIncorrectIdentity, got %v", err)
}
} else {

View File

@@ -7,6 +7,7 @@
package main
import (
"bytes"
"errors"
"fmt"
"os"
@@ -46,6 +47,59 @@ func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err
return fileKey, err
}
type EncryptedIdentity struct {
Contents []byte
Passphrase func() (string, error)
NoMatchWarning func()
identities []age.Identity
}
var _ age.Identity = &EncryptedIdentity{}
func (i *EncryptedIdentity) Recipients() ([]age.Recipient, error) {
if i.identities == nil {
if err := i.decrypt(); err != nil {
return nil, err
}
}
return identitiesToRecipients(i.identities)
}
func (i *EncryptedIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) {
if i.identities == nil {
if err := i.decrypt(); err != nil {
return nil, err
}
}
for _, id := range i.identities {
fileKey, err = id.Unwrap(stanzas)
if errors.Is(err, age.ErrIncorrectIdentity) {
continue
}
if err != nil {
return nil, err
}
return fileKey, nil
}
i.NoMatchWarning()
return nil, age.ErrIncorrectIdentity
}
func (i *EncryptedIdentity) decrypt() error {
d, err := age.Decrypt(bytes.NewReader(i.Contents), &LazyScryptIdentity{i.Passphrase})
if e := new(age.NoIdentityMatchError); errors.As(err, &e) {
return fmt.Errorf("identity file is encrypted with age but not with a passphrase")
}
if err != nil {
return fmt.Errorf("failed to decrypt identity file: %v", err)
}
i.identities, err = age.ParseIdentities(d)
return err
}
// readPassphrase reads a passphrase from the terminal. It does not read from a
// non-terminal stdin, so it does not check stdinInUse.
func readPassphrase(prompt string) ([]byte, error) {

View File

@@ -18,6 +18,7 @@ import (
"filippo.io/age"
"filippo.io/age/agessh"
"filippo.io/age/armor"
"golang.org/x/crypto/cryptobyte"
"golang.org/x/crypto/ssh"
)
@@ -117,8 +118,8 @@ func sshKeyType(s string) (string, bool) {
}
// parseIdentitiesFile parses a file that contains age or SSH keys. It returns
// one of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, or
// *agessh.EncryptedSSHIdentity.
// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity,
// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity.
func parseIdentitiesFile(name string) ([]age.Identity, error) {
var f *os.File
if name == "-" {
@@ -137,8 +138,40 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
}
b := bufio.NewReader(f)
const pemHeader = "-----BEGIN"
if peeked, _ := b.Peek(len(pemHeader)); string(peeked) == pemHeader {
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
peeked := string(p)
switch {
// An age encrypted file, plain or armored.
case peeked == "age-encryption" || peeked == "-----BEGIN AGE":
var r io.Reader = b
if peeked == "-----BEGIN AGE" {
r = armor.NewReader(r)
}
const privateKeySizeLimit = 1 << 24 // 16 MiB
contents, err := ioutil.ReadAll(io.LimitReader(r, privateKeySizeLimit))
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
}
if len(contents) == privateKeySizeLimit {
return nil, fmt.Errorf("failed to read %q: file too long", name)
}
return []age.Identity{&EncryptedIdentity{
Contents: contents,
Passphrase: func() (string, error) {
pass, err := readPassphrase(fmt.Sprintf("Enter passphrase for identity file %q:", name))
if err != nil {
return "", fmt.Errorf("could not read passphrase: %v", err)
}
return string(pass), nil
},
NoMatchWarning: func() {
fmt.Fprintf(os.Stderr, "Warning: encrypted identity file %q didn't match file's recipients\n", name)
},
}}, nil
// Another PEM file, possibly an SSH private key.
case strings.HasPrefix(peeked, "-----BEGIN"):
const privateKeySizeLimit = 1 << 14 // 16 KiB
contents, err := ioutil.ReadAll(io.LimitReader(b, privateKeySizeLimit))
if err != nil {
@@ -148,13 +181,15 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) {
return nil, fmt.Errorf("failed to read %q: file too long", name)
}
return parseSSHIdentity(name, contents)
}
ids, err := age.ParseIdentities(b)
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
// An unencrypted age identity file.
default:
ids, err := age.ParseIdentities(b)
if err != nil {
return nil, fmt.Errorf("failed to read %q: %v", name, err)
}
return ids, nil
}
return ids, nil
}
func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) {

View File

@@ -97,12 +97,18 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext.
a\. A file listing [IDENTITIES][RECIPIENTS AND IDENTITIES] one per line.
Empty lines and lines starting with "`#`" are ignored as comments.
b\. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format.
b\. A passphrase encrypted age file, containing
[IDENTITIES][RECIPIENTS AND IDENTITIES] one per line like above.
The passphrase is requested interactively. Note that passphrase-protected
identity files are not necessary for most use cases, where access to the
encrypted identity file implies access to the whole system.
c\. An SSH private key file, in PKCS#1, PKCS#8, or OpenSSH format.
If the private key is password-protected, the password is requested
interactively only if the SSH identity matches the file. See the
[SSH keys][] section for more information, including supported key types.
c\. "`-`", causing one of the options above to be read from standard input.
d\. "`-`", causing one of the options above to be read from standard input.
In this case, the <INPUT> argument must be specified.
This option can be repeated. Identities are tried in the order in which
@@ -202,6 +208,18 @@ Encrypt and decrypt a file using a passphrase:
$ age -d secrets.txt.age > secrets.txt
Enter passphrase:
Encrypt and decrypt with a passphrase-protected identity file:
$ age-keygen | age -p > key.age
Public key: age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5
Enter passphrase (leave empty to autogenerate a secure one):
Using the autogenerated passphrase "hip-roast-boring-snake-mention-east-wasp-honey-input-actress".
$ age -r age1yhm4gctwfmrpz87tdslm550wrx6m79y9f2hdzt0lndjnehwj0ukqrjpyx5 secrets.txt > secrets.txt.age
$ age -d -i key.age secrets.txt.age > secrets.txt
Enter passphrase for identity file "key.age":
Encrypt and decrypt with an SSH public key:
$ age -R ~/.ssh/id_ed25519.pub example.jpg > example.jpg.age