From 1cb56ac42ea0d39c06560d026cb30e338cad1ef2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 20 Oct 2023 11:09:35 +0100 Subject: [PATCH] age-plugin support --- age/keysource.go | 202 ++++++++++++++++++++++++++++++++++++++++++++--- go.mod | 2 +- go.sum | 2 + 3 files changed, 196 insertions(+), 10 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index 9ee6be491b..c215bb37bd 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -1,6 +1,7 @@ package age import ( + "bufio" "bytes" "errors" "fmt" @@ -12,8 +13,10 @@ import ( "filippo.io/age" "filippo.io/age/armor" + "filippo.io/age/plugin" "github.com/getsops/sops/v3/logging" "github.com/sirupsen/logrus" + "golang.org/x/term" ) const ( @@ -57,7 +60,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 @@ -108,6 +111,7 @@ type ParsedIdentities []age.Identity // parsing (using age.ParseIdentities) and appending to the slice yourself, in // combination with e.g. a sync.Mutex. func (i *ParsedIdentities) Import(identity ...string) error { + fmt.Printf("importing identity: %s\n", identity) identities, err := parseIdentities(identity...) if err != nil { return fmt.Errorf("failed to parse and add to age identities: %w", err) @@ -239,7 +243,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) @@ -276,7 +280,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) } @@ -285,23 +294,158 @@ 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() + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) + if err != nil { + return err + } + defer out.Close() + return f(in, out) + } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { + defer tty.Close() + 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 } @@ -309,3 +453,43 @@ func parseIdentities(identity ...string) (ParsedIdentities, error) { } 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 +} diff --git a/go.mod b/go.mod index 3f1ceb4efb..1ede70e06c 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( cloud.google.com/go/kms v1.15.3 cloud.google.com/go/storage v1.33.0 - filippo.io/age v1.1.1 + filippo.io/age v1.1.2-0.20230920124100-101cc8676386 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0 github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 diff --git a/go.sum b/go.sum index 5a36464973..9907ec8dbc 100644 --- a/go.sum +++ b/go.sum @@ -13,6 +13,8 @@ cloud.google.com/go/storage v1.33.0 h1:PVrDOkIC8qQVa1P3SXGpQvfuJhN2LHOoyZvWs8D2X cloud.google.com/go/storage v1.33.0/go.mod h1:Hhh/dogNRGca7IWv1RC2YqEn0c0G77ctA/OxflYkiD8= 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.20230920124100-101cc8676386 h1:cMNckh2AzWP8qr+i+WgCVteMLFoDOkLy+6AsYRWwfVU= +filippo.io/age v1.1.2-0.20230920124100-101cc8676386/go.mod h1:y3Zb/i2jHg/kL8xc3ocrI0Wd0Vm+VWV6DKfsKzSGUmU= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0 h1:9kDVnTz3vbfweTqAUmk/a/pH5pWFCHtvRpHYC0G/dcA= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.8.0/go.mod h1:3Ug6Qzto9anB6mGlEdgYMDF5zHQ+wwhEaYR4s17PHMw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.4.0 h1:BMAjVKJM0U/CYF27gA0ZMmXGkOcvfFtD0oHVZ1TIPRI=