Skip to content

Commit

Permalink
age-plugin support
Browse files Browse the repository at this point in the history
Signed-off-by: Jörg Thalheim <[email protected]>
  • Loading branch information
Mic92 committed Mar 18, 2024
1 parent d8e8809 commit 54c64d5
Show file tree
Hide file tree
Showing 3 changed files with 195 additions and 10 deletions.
201 changes: 192 additions & 9 deletions age/keysource.go
@@ -1,6 +1,7 @@
package age

import (
"bufio"
"bytes"
"errors"
"fmt"
Expand All @@ -12,9 +13,11 @@ import (

"filippo.io/age"
"filippo.io/age/armor"
"filippo.io/age/plugin"
"github.com/sirupsen/logrus"

"github.com/getsops/sops/v3/logging"
"golang.org/x/term"
)

const (
Expand Down Expand Up @@ -60,7 +63,7 @@ type MasterKey struct {
parsedIdentities []age.Identity
// parsedRecipient contains a parsed age public key.
// It is used to lazy-load the Recipient at-most once.
parsedRecipient *age.X25519Recipient
parsedRecipient age.Recipient
}

// MasterKeysFromRecipients takes a comma-separated list of Bech32-encoded
Expand Down Expand Up @@ -247,7 +250,7 @@ func getUserConfigDir() (string, error) {
// SopsAgeKeyUserConfigPath). It will load all found references, and expects
// at least one configuration to be present.
func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
var readers = make(map[string]io.Reader, 0)
readers := make(map[string]io.Reader, 0)

if ageKey, ok := os.LookupEnv(SopsAgeKeyEnv); ok {
readers[SopsAgeKeyEnv] = strings.NewReader(ageKey)
Expand Down Expand Up @@ -284,7 +287,12 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {

var identities ParsedIdentities
for n, r := range readers {
ids, err := age.ParseIdentities(r)
buf := new(strings.Builder)
_, err := io.Copy(buf, r)
if err != nil {
return nil, fmt.Errorf("failed to read '%s' age identities: %w", n, err)
}
ids, err := parseIdentities(buf.String())
if err != nil {
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", n, err)
}
Expand All @@ -293,27 +301,202 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
return identities, nil
}

// clearLine clears the current line on the terminal, or opens a new line if
// terminal escape codes don't work.
func clearLine(out io.Writer) {
const (
CUI = "\033[" // Control Sequence Introducer
CPL = CUI + "F" // Cursor Previous Line
EL = CUI + "K" // Erase in Line
)

// First, open a new line, which is guaranteed to work everywhere. Then, try
// to erase the line above with escape codes.
//
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
// cursor would not go back to the start of the line with a simple LF.
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
fmt.Fprintf(out, "\r\n"+CPL+EL)
}

func withTerminal(f func(in, out *os.File) error) error {
if runtime.GOOS == "windows" {
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
if err != nil {
return err
}
defer in.Close()

Check warning

Code scanning / CodeQL

Writable file handle closed without error handling Warning

File handle may be writable as a result of data flow from a
call to OpenFile
and closing it may result in data loss upon failure, which is not handled explicitly.
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
if err != nil {
return err
}
defer out.Close()

Check warning

Code scanning / CodeQL

Writable file handle closed without error handling Warning

File handle may be writable as a result of data flow from a
call to OpenFile
and closing it may result in data loss upon failure, which is not handled explicitly.
return f(in, out)
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
defer tty.Close()

Check warning

Code scanning / CodeQL

Writable file handle closed without error handling Warning

File handle may be writable as a result of data flow from a
call to OpenFile
and closing it may result in data loss upon failure, which is not handled explicitly.
return f(tty, tty)
} else if term.IsTerminal(int(os.Stdin.Fd())) {
return f(os.Stdin, os.Stdin)
} else {
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
}
}

// readSecret reads a value from the terminal with no echo. The prompt is ephemeral.
func readSecret(prompt string) (s []byte, err error) {
err = withTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
defer clearLine(out)
s, err = term.ReadPassword(int(in.Fd()))
return err
})
return
}

// readCharacter reads a single character from the terminal with no echo. The
// prompt is ephemeral.
func readCharacter(prompt string) (c byte, err error) {
err = withTerminal(func(in, out *os.File) error {
fmt.Fprintf(out, "%s ", prompt)
defer clearLine(out)

oldState, err := term.MakeRaw(int(in.Fd()))
if err != nil {
return err
}
defer term.Restore(int(in.Fd()), oldState)

b := make([]byte, 1)
if _, err := in.Read(b); err != nil {
return err
}

c = b[0]
return nil
})
return
}

var pluginTerminalUI = &plugin.ClientUI{
DisplayMessage: func(name, message string) error {
log.Info("%s plugin: %s", name, message)
return nil
},
RequestValue: func(name, message string, _ bool) (s string, err error) {
defer func() {
if err != nil {
log.Warn("could not read value for age-plugin-%s: %v", name, err)
}
}()
secret, err := readSecret(message)
if err != nil {
return "", err
}
return string(secret), nil
},
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
defer func() {
if err != nil {
log.Warn("could not read value for age-plugin-%s: %v", name, err)
}
}()
if no == "" {
message += fmt.Sprintf(" (press enter for %q)", yes)
_, err := readSecret(message)
if err != nil {
return false, err
}
return true, nil
}
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
for {
selection, err := readCharacter(message)
if err != nil {
return false, err
}
switch selection {
case '1':
return true, nil
case '2':
return false, nil
case '\x03': // CTRL-C
return false, errors.New("user cancelled prompt")
default:
log.Warn("reading value for age-plugin-%s: invalid selection %q", name, selection)
}
}
},
WaitTimer: func(name string) {
log.Info("waiting on %s plugin...", name)
},
}

// parseRecipient attempts to parse a string containing an encoded age public
// key.
func parseRecipient(recipient string) (*age.X25519Recipient, error) {
parsedRecipient, err := age.ParseX25519Recipient(recipient)
if err != nil {
return nil, fmt.Errorf("failed to parse input as Bech32-encoded age public key: %w", err)
func parseRecipient(recipient string) (age.Recipient, error) {
switch {
case strings.HasPrefix(recipient, "age1") && strings.Count(recipient, "1") > 1:
return plugin.NewRecipient(recipient, pluginTerminalUI)
case strings.HasPrefix(recipient, "age1"):
return age.ParseX25519Recipient(recipient)
}
return parsedRecipient, nil

return nil, fmt.Errorf("unknown recipient type: %q", recipient)
}

// parseIdentities attempts to parse the string set of encoded age identities.
// A single identity argument is allowed to be a multiline string containing
// multiple identities. Empty lines and lines starting with "#" are ignored.
func parseIdentities(identity ...string) (ParsedIdentities, error) {
log.Warn("parsing identity: %s", identity)
var identities []age.Identity
for _, i := range identity {
parsed, err := age.ParseIdentities(strings.NewReader(i))
parsed, err := _parseIdentities(strings.NewReader(i))
if err != nil {
return nil, err
}
identities = append(identities, parsed...)
}
return identities, nil
}

func parseIdentity(s string) (age.Identity, error) {
switch {
case strings.HasPrefix(s, "AGE-PLUGIN-"):
return plugin.NewIdentity(s, pluginTerminalUI)
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
return age.ParseX25519Identity(s)
default:
return nil, fmt.Errorf("unknown identity type")
}
}

// parseIdentities is like age.ParseIdentities, but supports plugin identities.
func _parseIdentities(f io.Reader) (ParsedIdentities, 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
}
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -5,7 +5,7 @@ go 1.19
require (
cloud.google.com/go/kms v1.15.7
cloud.google.com/go/storage v1.38.0
filippo.io/age v1.1.1
filippo.io/age v1.1.2-0.20240110114017-29b68c20fc24
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Expand Up @@ -13,6 +13,8 @@ cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkp
cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY=
filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg=
filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE=
filippo.io/age v1.1.2-0.20240110114017-29b68c20fc24 h1:vQIe2pCVvdZjX8OtZjbJ33nBKPjTnmy0zbdJxRjhH3w=
filippo.io/age v1.1.2-0.20240110114017-29b68c20fc24/go.mod h1:y3Zb/i2jHg/kL8xc3ocrI0Wd0Vm+VWV6DKfsKzSGUmU=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2 h1:c4k2FIYIh4xtwqrQwV0Ct1v5+ehlNXj5NI/MWVsiTkQ=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.9.2/go.mod h1:5FDJtLEO/GxwNgUxbwrY3LP0pEoThTQJtk2oysdXHxM=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
Expand Down

0 comments on commit 54c64d5

Please sign in to comment.