From a077fbf103edd823ca7be8dddd01f4f1703da40e 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörg Thalheim --- age/keysource.go | 200 ++++++++++++++++++++++++++++++++++++++++-- age/keysource_test.go | 2 +- go.mod | 2 +- go.sum | 5 +- 4 files changed, 196 insertions(+), 13 deletions(-) diff --git a/age/keysource.go b/age/keysource.go index 83bdbe0a6..4378509bd 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -1,6 +1,7 @@ package age import ( + "bufio" "bytes" "errors" "fmt" @@ -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 ( @@ -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 @@ -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) @@ -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) } @@ -293,14 +301,148 @@ 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.Infof("%s plugin: %s", name, message) + return nil + }, + RequestValue: func(name, message string, _ bool) (s string, err error) { + defer func() { + if err != nil { + log.Warnf("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.Warnf("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.Warnf("reading value for age-plugin-%s: invalid selection %q", name, selection) + } + } + }, + WaitTimer: func(name string) { + log.Infof("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. @@ -309,7 +451,7 @@ func parseRecipient(recipient string) (*age.X25519Recipient, error) { func parseIdentities(identity ...string) (ParsedIdentities, error) { 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 } @@ -317,3 +459,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/age/keysource_test.go b/age/keysource_test.go index 1a07058a6..b196aa81f 100644 --- a/age/keysource_test.go +++ b/age/keysource_test.go @@ -134,7 +134,7 @@ func TestMasterKey_Encrypt(t *testing.T) { } err := key.Encrypt([]byte(mockEncryptedKeyPlain)) assert.Error(t, err) - assert.ErrorContains(t, err, "failed to parse input as Bech32-encoded age public key") + assert.ErrorContains(t, err, "unknown recipient type:") assert.Empty(t, key.EncryptedKey) }) diff --git a/go.mod b/go.mod index 654675080..ed336cfa5 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 01ad99732..60a593853 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +c2sp.org/CCTV/age v0.0.0-20221230231406-5ea85644bd03 h1:0e2QjhWG02SgzlUOvNYaFraf04OBsUPOLxf+K+Ae/yM= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= @@ -11,8 +12,8 @@ cloud.google.com/go/kms v1.15.7 h1:7caV9K3yIxvlQPAcaFffhlT7d1qpxjB1wHBtjWa13SM= cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI= cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= 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=