1
0
mirror of https://github.com/getsops/sops.git synced 2026-02-05 12:45:21 +01:00

Merge pull request #1641 from brianmcgee/feat/age-plugins

feat: add age plugin support
This commit is contained in:
Felix Fontein
2025-02-27 21:10:22 +01:00
committed by GitHub
3 changed files with 221 additions and 36 deletions

View File

@@ -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
}
}
}

View File

@@ -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")
}
}

View File

@@ -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
if runtime.GOOS == "windows" {
var err error
in, err = os.OpenFile("CONIN$", os.O_RDWR, 0)
var (
err error
passphrase []byte
)
err = withTerminal(func(in, out *os.File) error {
_, err := fmt.Fprintf(out, "%s ", prompt)
if err != nil {
return nil, err
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" {
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)
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)
},
}