From fb97277f8dd5d3a75053424cb36f4b55a2a243c0 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 7 Jun 2021 23:43:01 +0200 Subject: [PATCH] cmd/age: add support for encrypted identity files Updates #252 Closes #132 --- README.md | 32 ++++++++++++++++++++--- agessh/agessh.go | 2 +- agessh/encrypted_keys.go | 31 +++++++++++++++------- cmd/age/age.go | 46 ++++++++++++++++++++------------- cmd/age/age_test.go | 4 +-- cmd/age/encrypted_keys.go | 54 +++++++++++++++++++++++++++++++++++++++ cmd/age/parse.go | 53 +++++++++++++++++++++++++++++++------- doc/age.1.ronn | 22 ++++++++++++++-- 8 files changed, 199 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 10c0309..0d47eb9 100644 --- a/README.md +++ b/README.md @@ -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.) diff --git a/agessh/agessh.go b/agessh/agessh.go index 86a73ac..bd59317 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -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 } diff --git a/agessh/encrypted_keys.go b/agessh/encrypted_keys.go index e0e6af9..999e5c2 100644 --- a/agessh/encrypted_keys.go +++ b/agessh/encrypted_keys.go @@ -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 diff --git a/cmd/age/age.go b/cmd/age/age.go index 1cda46d..32ac118 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -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 { diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 8e1517e..7e07464 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -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 { diff --git a/cmd/age/encrypted_keys.go b/cmd/age/encrypted_keys.go index 9b31a63..8e95df9 100644 --- a/cmd/age/encrypted_keys.go +++ b/cmd/age/encrypted_keys.go @@ -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) { diff --git a/cmd/age/parse.go b/cmd/age/parse.go index addb7c7..0ae87e9 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -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) { diff --git a/doc/age.1.ronn b/doc/age.1.ronn index c0c1bf4..b1f53ef 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -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 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