From 6157d86d75242cea4edaa4e32f492bc4e2ba46f0 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Wed, 2 Oct 2024 11:44:38 +0100 Subject: [PATCH] feat: add age plugin support Signed-off-by: Brian McGee --- age/encrypted_keys.go | 6 +- age/keysource.go | 51 +++++++++-- age/tui.go | 198 +++++++++++++++++++++++++++++++++++++----- 3 files changed, 220 insertions(+), 35 deletions(-) diff --git a/age/encrypted_keys.go b/age/encrypted_keys.go index 6928b2915..12e7c5264 100644 --- a/age/encrypted_keys.go +++ b/age/encrypted_keys.go @@ -104,7 +104,7 @@ func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err return fileKey, err } -func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error){ +func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error) { b := bufio.NewReader(reader) p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE" peeked := string(p) @@ -181,10 +181,10 @@ func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error){ return ids, nil // An unencrypted age identity file. default: - ids, err := age.ParseIdentities(b) + ids, err := parseIdentities(b) if err != nil { return nil, fmt.Errorf("failed to parse '%s' age identities: %w", key, err) } return ids, nil } -} \ No newline at end of file +} diff --git a/age/keysource.go b/age/keysource.go index 5530614c9..f5f168576 100644 --- a/age/keysource.go +++ b/age/keysource.go @@ -1,8 +1,10 @@ package age import ( + "bufio" "bytes" "errors" + "filippo.io/age/plugin" "fmt" "io" "os" @@ -115,7 +117,10 @@ 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 { - identities, err := parseIdentities(identity...) + // one identity per line + r := strings.NewReader(strings.Join(identity, "\n")) + + identities, err := parseIdentities(r) if err != nil { return fmt.Errorf("failed to parse and add to age identities: %w", err) } @@ -339,6 +344,12 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) { // key or a public ssh key. func parseRecipient(recipient string) (age.Recipient, error) { switch { + case strings.HasPrefix(recipient, "age1") && strings.Count(recipient, "1") > 1: + parsedRecipient, err := plugin.NewRecipient(recipient, pluginTerminalUI) + if err != nil { + return nil, fmt.Errorf("failed to parse input as age key from age plugin: %w", err) + } + return parsedRecipient, nil case strings.HasPrefix(recipient, "age1"): parsedRecipient, err := age.ParseX25519Recipient(recipient) if err != nil { @@ -357,17 +368,39 @@ func parseRecipient(recipient string) (age.Recipient, error) { return nil, fmt.Errorf("failed to parse input, 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) { - var identities []age.Identity - for _, i := range identity { - parsed, err := age.ParseIdentities(strings.NewReader(i)) +// parseIdentities attempts to parse one or more age identities from the provided reader. +// One identity per line. +// Empty lines and lines starting with "#" are ignored. +func parseIdentities(r io.Reader) (ParsedIdentities, error) { + var identities ParsedIdentities + + scanner := bufio.NewScanner(r) + + for scanner.Scan() { + line := scanner.Text() + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parsed, err := parseIdentity(line) if err != nil { return nil, err } - identities = append(identities, parsed...) + + 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") + } +} diff --git a/age/tui.go b/age/tui.go index 43a2e4343..c6c15fb9b 100644 --- a/age/tui.go +++ b/age/tui.go @@ -1,5 +1,6 @@ // These functions have been copied from the age project // https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/encrypted_keys.go +// https://github.com/FiloSottile/age/blob/3d91014ea095e8d70f7c6c4833f89b53a96e0832/cmd/age/tui.go // // Copyright 2021 The age Authors. All rights reserved. // Use of this source code is governed by a BSD-style @@ -11,7 +12,10 @@ package age import ( + "errors" + "filippo.io/age/plugin" "fmt" + "io" "os" "runtime" "testing" @@ -33,37 +37,185 @@ func readPassphrase(prompt string) ([]byte, error) { } } - var in, out *os.File + var ( + err error + passphrase []byte + ) + + err = withTerminal(func(in, out *os.File) error { + _, err := fmt.Fprintf(out, "%s ", prompt) + if err != nil { + return fmt.Errorf("could not write prompt: %v", err) + } + + // Use CRLF 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$ even work at all inside WSL2. + defer fmt.Fprintf(out, "\r\n") + + if passphrase, err = term.ReadPassword(int(in.Fd())); err != nil { + return fmt.Errorf("could not read passphrase: %v", err) + } + + return nil + }) + + return passphrase, err +} + +func printf(format string, v ...interface{}) { + log.Printf("age: "+format, v...) +} + +func warningf(format string, v ...interface{}) { + log.Printf("age: warning: "+format, v...) +} + +// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and +// panic instead of calling os.Exit. This way, the wrapper in TestMain can +// recover the panic and return the exit code only if it was originated in exit. +var testOnlyPanicInsteadOfExit bool +var testOnlyDidExit bool + +func exit(code int) { + if testOnlyPanicInsteadOfExit { + testOnlyDidExit = true + panic(code) + } + os.Exit(code) +} + +// 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) +} + +// withTerminal runs f with the terminal input and output files, if available. +// withTerminal does not open a non-terminal stdin, so the caller does not need +// to check stdinInUse. +func withTerminal(f func(in, out *os.File) error) error { if runtime.GOOS == "windows" { - var err error - in, err = os.OpenFile("CONIN$", os.O_RDWR, 0) + in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) if err != nil { - return nil, err + return err } defer in.Close() - out, err = os.OpenFile("CONOUT$", os.O_WRONLY, 0) + out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0) if err != nil { - return nil, err + return err } defer out.Close() - } else if _, err := os.Stat("/dev/tty"); err == nil { - tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) - if err != nil { - return nil, err - } + return f(in, out) + } else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil { defer tty.Close() - in, out = tty, tty + return f(tty, tty) + } else if term.IsTerminal(int(os.Stdin.Fd())) { + return f(os.Stdin, os.Stdin) } else { - if !term.IsTerminal(int(os.Stdin.Fd())) { - return nil, fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) - } - in, out = os.Stdin, os.Stderr + return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err) } - fmt.Fprintf(out, "%s ", prompt) - // Use CRLF 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$ even work at all inside WSL2. - defer fmt.Fprintf(out, "\r\n") - return term.ReadPassword(int(in.Fd())) +} + +// 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 { + printf("%s plugin: %s", name, message) + return nil + }, + RequestValue: func(name, message string, _ bool) (s string, err error) { + defer func() { + if err != nil { + warningf("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 { + warningf("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: + warningf("reading value for age-plugin-%s: invalid selection %q", name, selection) + } + } + }, + WaitTimer: func(name string) { + printf("waiting on %s plugin...", name) + }, }