From f6a5b94705cdcaf12eec592a516d355d33d73f90 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 4 Jan 2021 01:08:42 +0100 Subject: [PATCH] internal/plugin,cmd/age: implement preliminary plugin client support --- cmd/age/age.go | 4 +- cmd/age/encrypted_keys.go | 2 +- cmd/age/parse.go | 44 +++++- internal/plugin/client.go | 280 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 internal/plugin/client.go diff --git a/cmd/age/age.go b/cmd/age/age.go index 7cf80d4..29747a6 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -249,7 +249,7 @@ func main() { } encryptPass(pass, in, out, armorFlag) default: - encryptKeys(recipientFlags, recipientsFileFlags, identityFlags, in, out, armorFlag) + encryptNotPass(recipientFlags, recipientsFileFlags, identityFlags, in, out, armorFlag) } } @@ -279,7 +279,7 @@ func passphrasePromptForEncryption() (string, error) { return p, nil } -func encryptKeys(keys, files, identities []string, in io.Reader, out io.Writer, armor bool) { +func encryptNotPass(keys, files, identities []string, in io.Reader, out io.Writer, armor bool) { var recipients []age.Recipient for _, arg := range keys { r, err := parseRecipient(arg) diff --git a/cmd/age/encrypted_keys.go b/cmd/age/encrypted_keys.go index 5abbccb..7cbf696 100644 --- a/cmd/age/encrypted_keys.go +++ b/cmd/age/encrypted_keys.go @@ -99,7 +99,7 @@ func (i *EncryptedIdentity) decrypt() error { if err != nil { return fmt.Errorf("failed to decrypt identity file: %v", err) } - i.identities, err = age.ParseIdentities(d) + i.identities, err = parseIdentities(d) return err } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 93a913c..e12534a 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -15,6 +15,7 @@ import ( "filippo.io/age" "filippo.io/age/agessh" "filippo.io/age/armor" + "filippo.io/age/internal/plugin" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" ) @@ -32,6 +33,8 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { + case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: + return plugin.NewRecipient(arg) case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): @@ -187,7 +190,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { // An unencrypted age identity file. default: - ids, err := age.ParseIdentities(b) + ids, err := parseIdentities(b) if err != nil { return nil, fmt.Errorf("failed to read %q: %v", name, err) } @@ -195,6 +198,45 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { } } +func parseIdentity(s string) (age.Identity, error) { + switch { + case strings.HasPrefix(s, "AGE-PLUGIN-"): + return plugin.NewIdentity(s) + case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): + return age.ParseX25519Identity(s) + } + return nil, fmt.Errorf("unknown identity type") +} + +// parseIdentities is like age.ParseIdentities, but supports plugin identities. +func parseIdentities(f io.Reader) ([]age.Identity, error) { + const privateKeySizeLimit = 1 << 24 // 16 MiB + var ids []age.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 := parseIdentity(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 parseSSHIdentity(name string, pemBytes []byte) ([]age.Identity, error) { id, err := agessh.ParseIdentity(pemBytes) if sshErr, ok := err.(*ssh.PassphraseMissingError); ok { diff --git a/internal/plugin/client.go b/internal/plugin/client.go new file mode 100644 index 0000000..c70c322 --- /dev/null +++ b/internal/plugin/client.go @@ -0,0 +1,280 @@ +// 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 plugin implements the age plugin protocol. +package plugin + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "filippo.io/age" + "filippo.io/age/internal/bech32" + "filippo.io/age/internal/format" +) + +type Recipient struct { + name string + encoding string +} + +var _ age.Recipient = &Recipient{} + +func NewRecipient(s string) (*Recipient, error) { + hrp, _, err := bech32.Decode(s) + if err != nil { + return nil, fmt.Errorf("invalid recipient encoding %q: %v", s, err) + } + if !strings.HasPrefix(hrp, "age1") { + return nil, fmt.Errorf("not a plugin recipient %q: %v", s, err) + } + name := strings.TrimPrefix(hrp, "age1") + return &Recipient{ + name: name, encoding: s, + }, nil +} + +// Name returns the plugin name, which is used in the recipient ("age1name1...") +// and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin +// binary name ("age-plugin-name"). +func (r *Recipient) Name() string { + return r.name +} + +func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + cmd := exec.Command("age-plugin-"+r.name, "--age-plugin=recipient-v1") + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + cmd.Dir = filepath.Clean("/") // TODO: does this work on Windows + if err := cmd.Start(); err != nil { + return nil, err + } + + // Phase 1: client sends recipient and file key + s := &format.Stanza{ + Type: "add-recipient", + Args: []string{r.encoding}, + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + + s = &format.Stanza{ + Type: "wrap-file-key", + Body: fileKey, + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + + s = &format.Stanza{ + Type: "done", + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + + // Phase 2: plugin responds with stanzas + var out []*age.Stanza + sr := format.NewStanzaReader(bufio.NewReader(stdout)) +ReadLoop: + for { + s, err := sr.ReadStanza() + if err != nil { + return nil, err + } + + switch s.Type { + case "recipient-stanza": + if len(s.Args) < 2 { + return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + } + n, err := strconv.Atoi(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + } + // Currently, we only send a single file key, so the index must be 0. + if n != 0 { + return nil, fmt.Errorf("plugin error: received malformed recipient stanza") + } + + out = append(out, &age.Stanza{ + Type: s.Args[1], + Args: s.Args[2:], + Body: s.Body, + }) + case "error": + return nil, fmt.Errorf("plugin error: %q", s.Body) + case "done": + break ReadLoop + default: + // Unknown commands are ignored. + } + } + + if len(out) == 0 { + return nil, fmt.Errorf("plugin error: received zero recipient stanzas") + } + + if err := stdin.Close(); err != nil { + return nil, err + } + if err := cmd.Wait(); err != nil { + return nil, err + } + + return out, nil +} + +type Identity struct { + name string + encoding string +} + +var _ age.Identity = &Identity{} + +func NewIdentity(s string) (*Identity, error) { + hrp, _, err := bech32.Decode(s) + if err != nil { + return nil, fmt.Errorf("invalid identity encoding: %v", err) + } + if !strings.HasPrefix(hrp, "AGE-PLUGIN-") || !strings.HasSuffix(hrp, "-") { + return nil, fmt.Errorf("not a plugin identity: %v", err) + } + name := strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") + name = strings.ToLower(name) + return &Identity{ + name: name, encoding: s, + }, nil +} + +// Name returns the plugin name, which is used in the recipient ("age1name1...") +// and identity ("AGE-PLUGIN-NAME-1...") encodings, as well as in the plugin +// binary name ("age-plugin-name"). +func (i *Identity) Name() string { + return i.name +} + +func (i *Identity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err error) { + // TODO: DRY up connection management into a connection type, and defer + // closing the connection. + cmd := exec.Command("age-plugin-"+i.name, "--age-plugin=identity-v1") + stderr := &bytes.Buffer{} + cmd.Stderr = stderr + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + cmd.Dir = filepath.Clean("/") // TODO: does this work on Windows + if err := cmd.Start(); err != nil { + return nil, err + } + + // Phase 1: client sends the plugin the identity string and the stanzas + s := &format.Stanza{ + Type: "add-identity", + Args: []string{i.encoding}, + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + + for _, rs := range stanzas { + s = &format.Stanza{ + Type: "recipient-stanza", + Args: append([]string{"0", rs.Type}, rs.Args...), + Body: rs.Body, + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + } + + s = &format.Stanza{ + Type: "done", + } + if err := s.Marshal(stdin); err != nil { + return nil, err + } + + // Phase 2: plugin responds with various commands and a file key + var out []byte + sr := format.NewStanzaReader(bufio.NewReader(stdout)) +ReadLoop: + for { + s, err := sr.ReadStanza() + if err != nil { + return nil, err + } + + switch s.Type { + case "msg": + // TODO: unimplemented. + ss := &format.Stanza{Type: "ok"} + if err := ss.Marshal(stdin); err != nil { + return nil, err + } + case "request-secret": + // TODO: unimplemented. + ss := &format.Stanza{Type: "fail"} + if err := ss.Marshal(stdin); err != nil { + return nil, err + } + case "file-key": + if len(s.Args) != 1 { + return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + } + n, err := strconv.Atoi(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + } + // Currently, we only send a single file key, so the index must be 0. + if n != 0 { + return nil, fmt.Errorf("plugin error: received malformed file-key stanza") + } + + out = s.Body + + ss := &format.Stanza{Type: "ok"} + if err := ss.Marshal(stdin); err != nil { + return nil, err + } + case "error": + ss := &format.Stanza{Type: "ok"} + if err := ss.Marshal(stdin); err != nil { + return nil, err + } + + return nil, fmt.Errorf("plugin error: %q", s.Body) + case "done": + break ReadLoop + default: + // Unknown commands are ignored. + } + } + + if out == nil { + return nil, age.ErrIncorrectIdentity + } + return out, nil +}