age: add ParseX25519Identities and key management docs

This commit is contained in:
Filippo Valsorda
2020-09-20 12:17:15 +02:00
parent 2194f6962c
commit 65f171a239
5 changed files with 112 additions and 46 deletions

23
age.go
View File

@@ -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 (

View File

@@ -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)
}
})
}
}

View File

@@ -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...)
}

View File

@@ -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) {

View File

@@ -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