diff --git a/cmd/age/age.go b/cmd/age/age.go index aa68f5f..bcddbb7 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -80,6 +80,24 @@ func (f *multiFlag) Set(value string) error { return nil } +type identityFlag struct { + Type, Value string +} + +// identityFlags tracks -i and -j flags, preserving their relative order, so +// that "age -d -j agent -i encrypted-fallback-keys.age" behaves as expected. +type identityFlags []identityFlag + +func (f *identityFlags) addIdentityFlag(value string) error { + *f = append(*f, identityFlag{Type: "i", Value: value}) + return nil +} + +func (f *identityFlags) addPluginFlag(value string) error { + *f = append(*f, identityFlag{Type: "j", Value: value}) + return nil +} + func main() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } @@ -92,8 +110,9 @@ func main() { outFlag string decryptFlag, encryptFlag bool passFlag, versionFlag, armorFlag bool - recipientFlags, identityFlags multiFlag + recipientFlags multiFlag recipientsFileFlags multiFlag + identityFlags identityFlags ) flag.BoolVar(&versionFlag, "version", false, "print the version") @@ -111,8 +130,9 @@ func main() { flag.Var(&recipientFlags, "recipient", "recipient (can be repeated)") flag.Var(&recipientsFileFlags, "R", "recipients file (can be repeated)") flag.Var(&recipientsFileFlags, "recipients-file", "recipients file (can be repeated)") - flag.Var(&identityFlags, "i", "identity (can be repeated)") - flag.Var(&identityFlags, "identity", "identity (can be repeated)") + flag.Func("i", "identity (can be repeated)", identityFlags.addIdentityFlag) + flag.Func("identity", "identity (can be repeated)", identityFlags.addIdentityFlag) + flag.Func("j", "data-less plugin (can be repeated)", identityFlags.addPluginFlag) flag.Parse() if versionFlag { @@ -185,7 +205,7 @@ func main() { } default: // encrypt if len(identityFlags) > 0 && !encryptFlag { - errorWithHint("-i/--identity can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", + errorWithHint("-i/--identity and -j can't be used in encryption mode unless symmetric encryption is explicitly selected with -e/--encrypt", "did you forget to specify -d/--decrypt?") } if len(recipientFlags)+len(recipientsFileFlags)+len(identityFlags) == 0 && !passFlag { @@ -199,7 +219,7 @@ func main() { errorf("-p/--passphrase can't be combined with -R/--recipients-file") } if len(identityFlags) > 0 && passFlag { - errorf("-p/--passphrase can't be combined with -i/--identity") + errorf("-p/--passphrase can't be combined with -i/--identity and -j") } } @@ -288,9 +308,9 @@ func passphrasePromptForEncryption() (string, error) { return p, nil } -func encryptNotPass(keys, files, identities []string, in io.Reader, out io.Writer, armor bool) { +func encryptNotPass(recs, files []string, identities identityFlags, in io.Reader, out io.Writer, armor bool) { var recipients []age.Recipient - for _, arg := range keys { + for _, arg := range recs { r, err := parseRecipient(arg) if err, ok := err.(gitHubRecipientError); ok { errorWithHint(err.Error(), "instead, use recipient files like", @@ -309,16 +329,25 @@ func encryptNotPass(keys, files, identities []string, in io.Reader, out io.Write } recipients = append(recipients, recs...) } - for _, name := range identities { - ids, err := parseIdentitiesFile(name) - if err != nil { - errorf("reading %q: %v", name, err) + for _, f := range identities { + switch f.Type { + case "i": + ids, err := parseIdentitiesFile(f.Value) + if err != nil { + errorf("reading %q: %v", f.Value, err) + } + r, err := identitiesToRecipients(ids) + if err != nil { + errorf("internal error processing %q: %v", f.Value, err) + } + recipients = append(recipients, r...) + case "j": + id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + if err != nil { + errorf("initializing %q: %v", f.Value, err) + } + recipients = append(recipients, id.Recipient()) } - r, err := identitiesToRecipients(ids) - if err != nil { - errorf("internal error processing %q: %v", name, err) - } - recipients = append(recipients, r...) } encrypt(recipients, in, out, armor) } @@ -359,19 +388,28 @@ func encrypt(recipients []age.Recipient, in io.Reader, out io.Writer, withArmor const crlfMangledIntro = "age-encryption.org/v1" + "\r" const utf16MangledIntro = "\xff\xfe" + "a\x00g\x00e\x00-\x00e\x00n\x00c\x00r\x00y\x00p\x00" -func decrypt(keys []string, in io.Reader, out io.Writer) { +func decrypt(flags identityFlags, in io.Reader, out io.Writer) { identities := []age.Identity{ // If there is an scrypt recipient (it will have to be the only one and) // this identity will be invoked. - &LazyScryptIdentity{passphrasePrompt}, + &LazyScryptIdentity{passphrasePromptForDecryption}, } - for _, name := range keys { - ids, err := parseIdentitiesFile(name) - if err != nil { - errorf("reading %q: %v", name, err) + for _, f := range flags { + switch f.Type { + case "i": + ids, err := parseIdentitiesFile(f.Value) + if err != nil { + errorf("reading %q: %v", f.Value, err) + } + identities = append(identities, ids...) + case "j": + id, err := plugin.NewIdentityWithoutData(f.Value, pluginTerminalUI) + if err != nil { + errorf("initializing %q: %v", f.Value, err) + } + identities = append(identities, id) } - identities = append(identities, ids...) } rr := bufio.NewReader(in) @@ -397,7 +435,7 @@ func decrypt(keys []string, in io.Reader, out io.Writer) { } } -func passphrasePrompt() (string, error) { +func passphrasePromptForDecryption() (string, error) { pass, err := readSecret("Enter passphrase:") if err != nil { return "", fmt.Errorf("could not read passphrase: %v", err) diff --git a/cmd/age/parse.go b/cmd/age/parse.go index bfdc341..709ee9e 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -31,14 +31,7 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: - r, err := plugin.NewRecipient(arg) - if err != nil { - return nil, err - } - r.DisplayMessage = pluginDisplayMessage(r.Name()) - r.RequestValue = pluginRequestSecret(r.Name()) - r.Confirm = pluginConfirm(r.Name()) - return r, nil + return plugin.NewRecipient(arg, pluginTerminalUI) case strings.HasPrefix(arg, "age1"): return age.ParseX25519Recipient(arg) case strings.HasPrefix(arg, "ssh-"): @@ -205,14 +198,7 @@ func parseIdentitiesFile(name string) ([]age.Identity, error) { func parseIdentity(s string) (age.Identity, error) { switch { case strings.HasPrefix(s, "AGE-PLUGIN-"): - i, err := plugin.NewIdentity(s) - if err != nil { - return nil, err - } - i.DisplayMessage = pluginDisplayMessage(i.Name()) - i.RequestValue = pluginRequestSecret(i.Name()) - i.Confirm = pluginConfirm(i.Name()) - return i, nil + return plugin.NewIdentity(s, pluginTerminalUI) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) default: diff --git a/cmd/age/tui.go b/cmd/age/tui.go index c6d5855..9accbe4 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -20,6 +20,7 @@ import ( "runtime" "strings" + "filippo.io/age/internal/plugin" "golang.org/x/term" ) @@ -96,25 +97,19 @@ func readSecret(prompt string) ([]byte, error) { return term.ReadPassword(int(in.Fd())) } -func pluginDisplayMessage(name string) func(string) error { - return func(message string) error { +var pluginTerminalUI = &plugin.ClientUI{ + DisplayMessage: func(name, message string) error { printf("%s plugin: %s", name, message) return nil - } -} - -func pluginRequestSecret(name string) func(string, bool) (string, error) { - return func(message string, _ bool) (string, error) { + }, + RequestValue: func(name, message string, _ bool) (string, error) { secret, err := readSecret(message) if err != nil { return "", fmt.Errorf("could not read value for age-plugin-%s: %v", name, err) } return string(secret), nil - } -} - -func pluginConfirm(name string) func(msg, yes, no string) (bool, error) { - return func(message, yes, no string) (bool, error) { + }, + Confirm: func(name, message, yes, no string) (bool, error) { if no != "" { message += fmt.Sprintf(" (1 for %q, 2 for %q)", yes, no) selection, err := readSecret(message) @@ -137,5 +132,5 @@ func pluginConfirm(name string) func(msg, yes, no string) (bool, error) { } return true, nil } - } + }, } diff --git a/doc/age.1.ronn b/doc/age.1.ronn index a614a97..2a8f5f8 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -5,7 +5,7 @@ age(1) -- simple, modern, and secure file encryption `age` [`--encrypt`] (`-r` | `-R` )... [`--armor`] [`-o` ] []
`age` [`--encrypt`] `--passphrase` [`--armor`] [`-o` ] []
-`age` `--decrypt` [`-i` ]... [`-o` ] []
+`age` `--decrypt` [`-i` | `-j` ]... [`-o` ] []
## DESCRIPTION @@ -120,6 +120,15 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext. corresponding to the `IDENTITIES` listed at . This allows using an identity file as a symmetric key, if desired. +* `-j` : + Decrypt using the data-less [plugin][Plugins] . + + This is equivalent to using `-i`/`--identity` with a file that contains a + single plugin `IDENTITY` that encodes no plugin-specific data. + + If `-e`/`--encrypt` is explicitly specified (to avoid confusion), `-j` may + also be used to encrypt with a data-less plugin. + ## RECIPIENTS AND IDENTITIES `RECIPIENTS` are public values, like a public key, that a file can be encrypted @@ -194,9 +203,12 @@ the plugin. For example, a plugin can be used to decrypt files encrypted to a native X25519 `RECIPIENT` or even with a passphrase. Similarly, a plugin can encrypt a file such that it can be decrypted without the use of any plugin. -Plugins for which the `IDENTITY`/`RECIPIENT` distinction doesn't make sense may -generate only an `IDENTITY` and instruct the user to perform encryption with the -`-e`/`--encrypt` and `-i`/`--identity` flags. +Plugins for which the `IDENTITY`/`RECIPIENT` distinction doesn't make sense +(such as a symmetric encryption plugin) may generate only an `IDENTITY` and +instruct the user to perform encryption with the `-e`/`--encrypt` and +`-i`/`--identity` flags. Plugins for which the concept of separate identities +doesn't make sense (such as a password-encryption plugin) may instruct the user +to use the `-j` flag. ## EXIT STATUS diff --git a/internal/plugin/client.go b/internal/plugin/client.go index 1026fa7..0c7dea7 100644 --- a/internal/plugin/client.go +++ b/internal/plugin/client.go @@ -26,16 +26,15 @@ import ( type Recipient struct { name string encoding string + ui *ClientUI // identity is true when encoding is an identity string. identity bool - - ClientUI } var _ age.Recipient = &Recipient{} -func NewRecipient(s string) (*Recipient, error) { +func NewRecipient(s string, ui *ClientUI) (*Recipient, error) { hrp, _, err := bech32.Decode(s) if err != nil { return nil, fmt.Errorf("invalid recipient encoding %q: %v", s, err) @@ -45,7 +44,7 @@ func NewRecipient(s string) (*Recipient, error) { } name := strings.TrimPrefix(hrp, "age1") return &Recipient{ - name: name, encoding: s, + name: name, encoding: s, ui: ui, }, nil } @@ -125,7 +124,7 @@ ReadLoop: case "done": break ReadLoop default: - if ok, err := r.handleUI(conn, s); err != nil { + if ok, err := r.ui.handle(r.name, conn, s); err != nil { return nil, err } else if !ok { if err := writeStanza(conn, "unsupported"); err != nil { @@ -145,13 +144,12 @@ ReadLoop: type Identity struct { name string encoding string - - ClientUI + ui *ClientUI } var _ age.Identity = &Identity{} -func NewIdentity(s string) (*Identity, error) { +func NewIdentity(s string, ui *ClientUI) (*Identity, error) { hrp, _, err := bech32.Decode(s) if err != nil { return nil, fmt.Errorf("invalid identity encoding: %v", err) @@ -162,7 +160,17 @@ func NewIdentity(s string) (*Identity, error) { name := strings.TrimSuffix(strings.TrimPrefix(hrp, "AGE-PLUGIN-"), "-") name = strings.ToLower(name) return &Identity{ - name: name, encoding: s, + name: name, encoding: s, ui: ui, + }, nil +} + +func NewIdentityWithoutData(name string, ui *ClientUI) (*Identity, error) { + s, err := bech32.Encode("AGE-PLUGIN-"+strings.ToUpper(name)+"-", nil) + if err != nil { + return nil, err + } + return &Identity{ + name: name, encoding: s, ui: ui, }, nil } @@ -181,7 +189,7 @@ func (i *Identity) Recipient() *Recipient { name: i.name, encoding: i.encoding, identity: true, - ClientUI: i.ClientUI, + ui: i.ui, } } @@ -256,7 +264,7 @@ ReadLoop: case "done": break ReadLoop default: - if ok, err := i.handleUI(conn, s); err != nil { + if ok, err := i.ui.handle(i.name, conn, s); err != nil { return nil, err } else if !ok { if err := writeStanza(conn, "unsupported"); err != nil { @@ -278,25 +286,25 @@ ReadLoop: type ClientUI struct { // DisplayMessage displays the message, which is expected to have lowercase // initials and no final period. - DisplayMessage func(message string) error + DisplayMessage func(name, message string) error // RequestValue requests a secret or public input, with the provided prompt. - RequestValue func(prompt string, secret bool) (string, error) + RequestValue func(name, prompt string, secret bool) (string, error) // Confirm requests a confirmation with the provided prompt. The yes and no // value are the choices provided to the user. no may be empty. The return // value indicates whether the user selected the yes or no option. - Confirm func(prompt, yes, no string) (choseYes bool, err error) + Confirm func(name, prompt, yes, no string) (choseYes bool, err error) } -func (c *ClientUI) handleUI(conn *clientConnection, s *format.Stanza) (ok bool, err error) { +func (c *ClientUI) handle(name string, conn *clientConnection, s *format.Stanza) (ok bool, err error) { // TODO: surface non-fatal but probably useful errors. switch s.Type { case "msg": if c.DisplayMessage == nil { return true, writeStanza(conn, "fail") } - if err := c.DisplayMessage(string(s.Body)); err != nil { + if err := c.DisplayMessage(name, string(s.Body)); err != nil { return true, writeStanza(conn, "fail") } return true, writeStanza(conn, "ok") @@ -304,7 +312,7 @@ func (c *ClientUI) handleUI(conn *clientConnection, s *format.Stanza) (ok bool, if c.RequestValue == nil { return true, writeStanza(conn, "fail") } - secret, err := c.RequestValue(string(s.Body), s.Type == "request-secret") + secret, err := c.RequestValue(name, string(s.Body), s.Type == "request-secret") if err != nil { return true, writeStanza(conn, "fail") } @@ -327,7 +335,7 @@ func (c *ClientUI) handleUI(conn *clientConnection, s *format.Stanza) (ok bool, return true, fmt.Errorf("malformed confirm stanza: invalid NO option encoding") } } - choseYes, err := c.Confirm(string(s.Body), string(yes), string(no)) + choseYes, err := c.Confirm(name, string(s.Body), string(yes), string(no)) if err != nil { return true, writeStanza(conn, "fail") }