Skip to content

Commit 3a2eda4

Browse files
brianmcgeeMa27
andcommitted
feat: add age plugin support
Signed-off-by: Brian McGee <[email protected]> Co-authored-by: Maximilian Bosch <[email protected]>
1 parent 146d12c commit 3a2eda4

File tree

3 files changed

+195
-12
lines changed

3 files changed

+195
-12
lines changed

age/encrypted_keys.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (i *LazyScryptIdentity) Unwrap(stanzas []*age.Stanza) (fileKey []byte, err
104104
return fileKey, err
105105
}
106106

107-
func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error){
107+
func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error) {
108108
b := bufio.NewReader(reader)
109109
p, _ := b.Peek(14) // length of "age-encryption" and "-----BEGIN AGE"
110110
peeked := string(p)
@@ -181,10 +181,10 @@ func unwrapIdentities(key string, reader io.Reader) (ParsedIdentities, error){
181181
return ids, nil
182182
// An unencrypted age identity file.
183183
default:
184-
ids, err := age.ParseIdentities(b)
184+
ids, err := parseIdentities(b)
185185
if err != nil {
186186
return nil, fmt.Errorf("failed to parse '%s' age identities: %w", key, err)
187187
}
188188
return ids, nil
189189
}
190-
}
190+
}

age/keysource.go

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package age
22

33
import (
4+
"bufio"
45
"bytes"
56
"errors"
7+
"filippo.io/age/plugin"
68
"fmt"
79
"io"
810
"os"
@@ -115,7 +117,10 @@ type ParsedIdentities []age.Identity
115117
// parsing (using age.ParseIdentities) and appending to the slice yourself, in
116118
// combination with e.g. a sync.Mutex.
117119
func (i *ParsedIdentities) Import(identity ...string) error {
118-
identities, err := parseIdentities(identity...)
120+
// one identity per line
121+
r := strings.NewReader(strings.Join(identity, "\n"))
122+
123+
identities, err := parseIdentities(r)
119124
if err != nil {
120125
return fmt.Errorf("failed to parse and add to age identities: %w", err)
121126
}
@@ -339,6 +344,12 @@ func (key *MasterKey) loadIdentities() (ParsedIdentities, error) {
339344
// key or a public ssh key.
340345
func parseRecipient(recipient string) (age.Recipient, error) {
341346
switch {
347+
case strings.HasPrefix(recipient, "age1") && strings.Count(recipient, "1") > 1:
348+
parsedRecipient, err := plugin.NewRecipient(recipient, PluginTerminalUI)
349+
if err != nil {
350+
return nil, fmt.Errorf("failed to parse input as age key from age plugin: %w", err)
351+
}
352+
return parsedRecipient, nil
342353
case strings.HasPrefix(recipient, "age1"):
343354
parsedRecipient, err := age.ParseX25519Recipient(recipient)
344355
if err != nil {
@@ -357,17 +368,39 @@ func parseRecipient(recipient string) (age.Recipient, error) {
357368
return nil, fmt.Errorf("failed to parse input, unknown recipient type: %q", recipient)
358369
}
359370

360-
// parseIdentities attempts to parse the string set of encoded age identities.
361-
// A single identity argument is allowed to be a multiline string containing
362-
// multiple identities. Empty lines and lines starting with "#" are ignored.
363-
func parseIdentities(identity ...string) (ParsedIdentities, error) {
364-
var identities []age.Identity
365-
for _, i := range identity {
366-
parsed, err := age.ParseIdentities(strings.NewReader(i))
371+
// parseIdentities attempts to parse one or more age identities from the provided reader.
372+
// One identity per line.
373+
// Empty lines and lines starting with "#" are ignored.
374+
func parseIdentities(r io.Reader) (ParsedIdentities, error) {
375+
var identities ParsedIdentities
376+
377+
scanner := bufio.NewScanner(r)
378+
379+
for scanner.Scan() {
380+
line := scanner.Text()
381+
382+
if line == "" || strings.HasPrefix(line, "#") {
383+
continue
384+
}
385+
386+
parsed, err := parseIdentity(line)
367387
if err != nil {
368388
return nil, err
369389
}
370-
identities = append(identities, parsed...)
390+
391+
identities = append(identities, parsed)
371392
}
393+
372394
return identities, nil
373395
}
396+
397+
func parseIdentity(s string) (age.Identity, error) {
398+
switch {
399+
case strings.HasPrefix(s, "AGE-PLUGIN-"):
400+
return plugin.NewIdentity(s, PluginTerminalUI)
401+
case strings.HasPrefix(s, "AGE-SECRET-KEY-1"):
402+
return age.ParseX25519Identity(s)
403+
default:
404+
return nil, fmt.Errorf("unknown identity type")
405+
}
406+
}

age/tui.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// These functions have been copied from the age project
22
// https://github.com/FiloSottile/age/blob/v1.0.0/cmd/age/encrypted_keys.go
3+
// https://github.com/FiloSottile/age/blob/266c0940916da6bfa310fdd23156fbd70f9d90a0/tui/tui.go
34
//
45
// Copyright 2021 The age Authors. All rights reserved.
56
// Use of this source code is governed by a BSD-style
@@ -11,7 +12,10 @@
1112
package age
1213

1314
import (
15+
"errors"
16+
"filippo.io/age/plugin"
1417
"fmt"
18+
"io"
1519
"os"
1620
"runtime"
1721
"testing"
@@ -67,3 +71,149 @@ func readPassphrase(prompt string) ([]byte, error) {
6771
defer fmt.Fprintf(out, "\r\n")
6872
return term.ReadPassword(int(in.Fd()))
6973
}
74+
75+
func Printf(format string, v ...interface{}) {
76+
log.Printf("age: "+format, v...)
77+
}
78+
79+
func Warningf(format string, v ...interface{}) {
80+
log.Printf("age: warning: "+format, v...)
81+
}
82+
83+
// ClearLine clears the current line on the terminal, or opens a new line if
84+
// terminal escape codes don't work.
85+
func ClearLine(out io.Writer) {
86+
const (
87+
CUI = "\033[" // Control Sequence Introducer
88+
CPL = CUI + "F" // Cursor Previous Line
89+
EL = CUI + "K" // Erase in Line
90+
)
91+
92+
// First, open a new line, which is guaranteed to work everywhere. Then, try
93+
// to erase the line above with escape codes.
94+
//
95+
// (We use CRLF instead of LF to work around an apparent bug in WSL2's
96+
// handling of CONOUT$. Only when running a Windows binary from WSL2, the
97+
// cursor would not go back to the start of the line with a simple LF.
98+
// Honestly, it's impressive CONIN$ and CONOUT$ work at all inside WSL2.)
99+
fmt.Fprintf(out, "\r\n"+CPL+EL)
100+
}
101+
102+
// WithTerminal runs f with the terminal input and output files, if available.
103+
// WithTerminal does not open a non-terminal stdin, so the caller does not need
104+
// to check stdinInUse.
105+
func WithTerminal(f func(in, out *os.File) error) error {
106+
if runtime.GOOS == "windows" {
107+
in, err := os.OpenFile("CONIN$", os.O_RDWR, 0)
108+
if err != nil {
109+
return err
110+
}
111+
defer in.Close()
112+
out, err := os.OpenFile("CONOUT$", os.O_WRONLY, 0)
113+
if err != nil {
114+
return err
115+
}
116+
defer out.Close()
117+
return f(in, out)
118+
} else if tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0); err == nil {
119+
defer tty.Close()
120+
return f(tty, tty)
121+
} else if term.IsTerminal(int(os.Stdin.Fd())) {
122+
return f(os.Stdin, os.Stdin)
123+
} else {
124+
return fmt.Errorf("standard input is not a terminal, and /dev/tty is not available: %v", err)
125+
}
126+
}
127+
128+
// ReadSecret reads a value from the terminal with no echo. The prompt is ephemeral.
129+
func ReadSecret(prompt string) (s []byte, err error) {
130+
err = WithTerminal(func(in, out *os.File) error {
131+
fmt.Fprintf(out, "%s ", prompt)
132+
defer ClearLine(out)
133+
s, err = term.ReadPassword(int(in.Fd()))
134+
return err
135+
})
136+
return
137+
}
138+
139+
// readCharacter reads a single character from the terminal with no echo. The
140+
// prompt is ephemeral.
141+
func readCharacter(prompt string) (c byte, err error) {
142+
err = WithTerminal(func(in, out *os.File) error {
143+
fmt.Fprintf(out, "%s ", prompt)
144+
defer ClearLine(out)
145+
146+
oldState, err := term.MakeRaw(int(in.Fd()))
147+
if err != nil {
148+
return err
149+
}
150+
defer term.Restore(int(in.Fd()), oldState)
151+
152+
b := make([]byte, 1)
153+
if _, err := in.Read(b); err != nil {
154+
return err
155+
}
156+
157+
c = b[0]
158+
return nil
159+
})
160+
return
161+
}
162+
163+
var PluginTerminalUI = &plugin.ClientUI{
164+
DisplayMessage: func(name, message string) error {
165+
Printf("%s plugin: %s", name, message)
166+
return nil
167+
},
168+
RequestValue: func(name, message string, _ bool) (s string, err error) {
169+
defer func() {
170+
if err != nil {
171+
Warningf("could not read value for age-plugin-%s: %v", name, err)
172+
}
173+
}()
174+
secret, err := ReadSecret(message)
175+
if err != nil {
176+
return "", err
177+
}
178+
return string(secret), nil
179+
},
180+
Confirm: func(name, message, yes, no string) (choseYes bool, err error) {
181+
defer func() {
182+
if err != nil {
183+
Warningf("could not read value for age-plugin-%s: %v", name, err)
184+
}
185+
}()
186+
if no == "" {
187+
message += fmt.Sprintf(" (press enter for %q)", yes)
188+
_, err := ReadSecret(message)
189+
if err != nil {
190+
return false, err
191+
}
192+
return true, nil
193+
}
194+
message += fmt.Sprintf(" (press [1] for %q or [2] for %q)", yes, no)
195+
for {
196+
selection, err := readCharacter(message)
197+
if err != nil {
198+
return false, err
199+
}
200+
switch selection {
201+
case '1':
202+
return true, nil
203+
case '2':
204+
return false, nil
205+
case '\x03': // CTRL-C
206+
return false, errors.New("user cancelled prompt")
207+
default:
208+
Warningf("reading value for age-plugin-%s: invalid selection %q", name, selection)
209+
}
210+
}
211+
},
212+
WaitTimer: func(name string) {
213+
Printf("waiting on %s plugin...", name)
214+
},
215+
}
216+
217+
type ReaderFunc func(p []byte) (n int, err error)
218+
219+
func (f ReaderFunc) Read(p []byte) (n int, err error) { return f(p) }

0 commit comments

Comments
 (0)