diff --git a/age.go b/age.go index 48539a9..a603eee 100644 --- a/age.go +++ b/age.go @@ -12,8 +12,29 @@ // ScryptRecipient and ScryptIdentity. For compatibility with existing SSH keys // use the filippo.io/age/agessh package. // -// Age encrypted files are binary and not malleable, for encoding them as text, +// Age encrypted files are binary and not malleable. For encoding them as text, // use the filippo.io/age/armor package. +// +// Key management +// +// Age does not have a global keyring. Instead, since age keys are small, +// textual, and cheap, you are encoraged to generate dedicated keys for each +// task and application. +// +// Recipient public keys can be passed around as command line flags and in +// config files, while secret keys should be stored in dedicated files, through +// secret management systems, or as environment variables. +// +// There is no default path for age keys. Instead, they should be stored at +// application-specific paths. The CLI supports files where private keys are +// listed one per line, ignoring empty lines and lines starting with "#". These +// files can be parsed with ParseX25519Identities. +// +// When integrating age into a new system, it's recommended that you only +// support X25519 keys, and not SSH keys. The latter are supported for manual +// encryption operations. If you need to tie into existing key management +// infrastructure, you might want to consider implementing your own Recipient +// and Identity. package age import ( diff --git a/age_test.go b/age_test.go index 43e9c8b..6e1245d 100644 --- a/age_test.go +++ b/age_test.go @@ -13,6 +13,7 @@ import ( "io" "io/ioutil" "log" + "strings" "testing" "filippo.io/age" @@ -162,3 +163,35 @@ func TestEncryptDecryptScrypt(t *testing.T) { t.Errorf("wrong data: %q, excepted %q", outBytes, helloWorld) } } + +func TestParseX25519Identities(t *testing.T) { + tests := []struct { + name string + wantCount int + wantErr bool + file string + }{ + {"valid", 2, false, ` +# this is a comment +# AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3 +# + +AGE-SECRET-KEY-1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59QJ +AGE-SECRET-KEY-19WUMFE89H3928FRJ5U3JYRNHM6CERQGKSQ584AQ8QY7T7R09D32SWE4DYH`}, + {"invalid", 0, true, ` +AGE-SECRET-KEY-1705XN76M8EYQ8M9PY4E2G3KA8DN7NSCGT3V4HMN20H3GCX4AS6HSSTG8D3 +AGE-SECRET-KEY--1D6K0SGAX3NU66R4GYFZY0UQWCLM3UUSF3CXLW4KXZM342WQSJ82QKU59Q`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := age.ParseX25519Identities(strings.NewReader(tt.file)) + if (err != nil) != tt.wantErr { + t.Errorf("ParseX25519Identities() error = %v, wantErr %v", err, tt.wantErr) + return + } + if len(got) != tt.wantCount { + t.Errorf("ParseX25519Identities() returned %d identities, want %d", len(got), tt.wantCount) + } + }) + } +} diff --git a/cmd/age/age.go b/cmd/age/age.go index 12679b4..f28233a 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -239,12 +239,12 @@ func decrypt(keys []string, in io.Reader, out io.Writer) { &LazyScryptIdentity{passphrasePrompt}, } - // TODO: use the default location if no arguments are provided: - // os.UserConfigDir()/age/keys.txt, ~/.ssh/id_rsa, ~/.ssh/id_ed25519 + // TODO: check the default SSH location if no arguments are provided + // (~/.ssh/id_rsa, ~/.ssh/id_ed25519). for _, name := range keys { ids, err := parseIdentitiesFile(name) if err != nil { - logFatalf("Error: %v", err) + logFatalf("Error reading %q: %v", name, err) } identities = append(identities, ids...) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 72e3ec4..622bd7a 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -8,7 +8,6 @@ package main import ( "bufio" - "bytes" "fmt" "io" "io/ioutil" @@ -31,8 +30,6 @@ func parseRecipient(arg string) (age.Recipient, error) { return nil, fmt.Errorf("unknown recipient type: %q", arg) } -const privateKeySizeLimit = 1 << 24 // 16 MiB - func parseIdentitiesFile(name string) ([]age.Identity, error) { f, err := os.Open(name) if err != nil { @@ -40,46 +37,29 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { } defer f.Close() - contents, err := ioutil.ReadAll(io.LimitReader(f, privateKeySizeLimit)) + b := bufio.NewReader(f) + const pemHeader = "-----BEGIN" + if peeked, _ := b.Peek(len(pemHeader)); string(peeked) == pemHeader { + const privateKeySizeLimit = 1 << 14 // 16 KiB + contents, err := ioutil.ReadAll(io.LimitReader(b, 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 parseSSHIdentity(name, contents) + } + + ids, err := age.ParseX25519Identities(b) 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) + res := make([]age.Identity, 0, len(ids)) + for _, id := range ids { + res = append(res, id) } - - var ids []age.Identity - var ageParsingError error - scanner := bufio.NewScanner(bytes.NewReader(contents)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "#") || line == "" { - continue - } - if strings.HasPrefix(line, "-----BEGIN") { - return parseSSHIdentity(name, contents) - } - if ageParsingError != nil { - continue - } - i, err := age.ParseX25519Identity(line) - if err != nil { - ageParsingError = fmt.Errorf("malformed secret keys file %q: %v", name, err) - continue - } - ids = append(ids, i) - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("failed to read %q: %v", name, err) - } - if ageParsingError != nil { - return nil, ageParsingError - } - - if len(ids) == 0 { - return nil, fmt.Errorf("no secret keys found in %q", name) - } - return ids, nil + return res, nil } func parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { diff --git a/x25519.go b/x25519.go index 8edd952..4375ee8 100644 --- a/x25519.go +++ b/x25519.go @@ -7,6 +7,7 @@ package age import ( + "bufio" "crypto/rand" "crypto/sha256" "errors" @@ -146,18 +147,49 @@ func GenerateX25519Identity() (*X25519Identity, error) { func ParseX25519Identity(s string) (*X25519Identity, error) { t, k, err := bech32.Decode(s) if err != nil { - return nil, fmt.Errorf("malformed secret key %q: %v", s, err) + return nil, fmt.Errorf("malformed secret key: %v", err) } if t != "AGE-SECRET-KEY-" { - return nil, fmt.Errorf("malformed secret key %q: invalid type %q", s, t) + return nil, fmt.Errorf("malformed secret key: invalid type %q", t) } r, err := newX25519IdentityFromScalar(k) if err != nil { - return nil, fmt.Errorf("malformed secret key %q: %v", s, err) + return nil, fmt.Errorf("malformed secret key: %v", err) } return r, nil } +// ParseX25519Identities parses a file with one or more Bech32 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. +func ParseX25519Identities(f io.Reader) ([]*X25519Identity, error) { + const privateKeySizeLimit = 1 << 24 // 16 MiB + var ids []*X25519Identity + 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