Try   HackMD

Plugin system for age

Introduction

The age file encryption format follows the "one well-oiled joint" design
philosophy. The mechanism for extensibility (within a particular format version)
is the recipient stanzas within the age header: file keys can be wrapped in any
number of ways, and age clients are required to ignore stanzas that they do not
understand. The core APIs that exercise this mechanism are:

  • A recipient that wraps a file key and returns a stanza.
  • An identity that unwraps a stanza and returns a file key.

Custom age clients can bundle support for the exact recipient or identity types
required for a particular environment. However, a general plugin system will
enable an ecosystem of common third-party recipient types to grow.

The plugin system specified in this document provides a mechanism for exposing
the core APIs across process boundaries. It has two main components:

  • A map from recipients and identities to plugin binaries.
  • State machines for wrapping and unwrapping file keys.

With this composable design, developers can implement a recipient or identity
that they would use directly with an age library, and then also deploy it as a
plugin binary.

Mapping recipients and identities to plugin binaries

age plugins are identified by an arbitrary case-insensitive string NAME. This
string is used in three places:

  • Plugin-compatible recipients are encoded using Bech32 with the HRP age1name
    (lowercase).
  • Plugin-compatible identities are encoded using Bech32 with the HRP
    AGE-PLUGIN-NAME- (uppercase).
  • Plugin binaries (to be started by age clients) are named age-plugin-name.

Users interact with age clients by providing either recipients for file
encryption, or identities for file decryption. When a plugin recipient or
identity is provided, the age client searches the PATH for a binary with the
corresponding plugin name.

Recipient stanza types are not required to be correlated to specific plugin
names. When decrypting, age clients will pass all recipient stanzas to every
connected plugin. Plugins MUST ignore stanzas that they do not know about.

A plugin binary may handle multiple recipient or identity types by being present
in the PATH under multiple names. This can be implemented with symlinks or
aliases to the canonical binary.

Multiple plugin binaries can support the same recipient and identity types; the
first binary found in the PATH will be used by age clients. Some Unix OSs
support "alternatives", which plugin binaries should leverage if they provide
support for a common recipient or identity type.

Note that the identity specified by a user doesn't need to point to a specific
decryption key, or indeed contain any key material at all. It only needs to
contain sufficient information for the plugin to locate the necessary key
material.

Standard age keys

A plugin MAY support decrypting files encrypted to native age recipients, by
including support for the x25519 recipient stanza. Such plugins will pick
their own name, and users will use identity files containing identities that
specify that plugin name.

Agents for age identities

One use-case for plugins is for implementing agents for age identities. The
expected design is a short-lifetime plugin binary age-plugin-NAME, which
implements the plugin protocol, and in turn connects to (or starts, if not
already running) a long-lifetime agent daemon. Specification of agents is
out of scope for this document.

State machines

A plugin operates using one of several state machines. The age client chooses
which state machine to use when it starts the plugin, by passing an argument
flag --age-plugin=STATE_MACHINE which specifies the type and version of the
state machine. This document defines the following state machines:

  • recipient-v1 - for wrapping file keys during file encryption.
  • identity-v1 - for unwrapping file keys during file decryption.

Plugins MUST NOT make any assumptions about the working directory that they are
run inside.

A plugin MUST refuse to start if it does not know about the requested state
machine; in this situation, or if a plugin otherwise terminates early with an
error, the age client:

  • MUST propagate the failure to the user if it occurs during file encryption
    (which would mean that the file could not be encrypted to one of the requested
    recipients). This may include displaying the contents of the plugin's standard
    error to the user.
  • MAY ignore the failure if it occurs during file decryption (and try another
    identity or plugin).

It is expected that the same plugin binary will be used (potentially with other
argument flags) for administrative tasks like generating keys.

IPC protocol

The IPC protocol for v1 state machines is built around an age stanza, using the
same text format as the age format v1 header:

  • The tag field is used for command and response types.
  • The arguments array is used for command-specific metadata.
  • The body contains data associated with the command, if any.

In the rest of this document, stanzas will be specified with the following
notations (optional fields indicated with [brackets]):

  • (COMMAND[, METADATA][; DATA])
  • (COMMAND, [METADATA] STANZA...) - STANZA is a complete stanza.
  • (COMMAND) - a command with no metadata or data.

Stanzas are serialized exactly as in age headers, using the explicit encoding
of stanza bodies. For example, if three commands are sent, and the second
command has no associated data:

-> command-1 metadata
Base64(data)
-> command-2 more metadata

-> command-3
Base64(lots of)
Base64(data)

Note that because the first line of an age stanza consists of SP-separated
arbitary strings, the tag field is always a valid argument. We leverage this in
order to send stanzas directly between the age client and plugin, by prepending
the stanza's first line with an appropriate command and/or additional metadata.

Communication between the client and plugin happens over the plugin's standard
input and standard output. This inherently makes the IPC uni-directionally
synchronous: stanzas have a well-defined order within a specific direction (e.g.
client to plugin). The IPC protocol does not track or enforce a bi-directional
ordering of stanzas; this is handled within the state machine.

Phases

The state machines for wrapping and unwrapping are composed of several phases.
Each phase is separated by an explicit done command with no metadata or data.
A phase is controlled by either the age client or the plugin; the controller
initiates all communication during the phase. There are two kinds of phases:

  • Uni-directional: the controller sends a series of commands, terminated by the
    done command. The other party is expected to store the effects of these
    commands for use in a subsequent phase.
  • Bi-directional: the controller sends a command, and synchronously waits for a
    response to that command. This is repeated until the controller terminates the
    phase with the done command.

State machine versions enable future specification of backwards-incompatible
state machines (that would likely be associated with new age format versions).
However, backwards-compatible changes may be made to existing state machines.
To ensure backwards-compatibility, other partys MUST handle receiving
unsupported commands. The other party's behaviour depends on the type of phase:

  • Uni-directional: the other party MUST ignore all commands it does not know
    about.
  • Bi-directional: the other party MUST respond with an unsupported command
    with no metadata or data.

Grease

To ensure that the above joint does not rust (and similarly to the age format
header), each phase supports the addition of "grease": age stanzas with random
commands (that MUST NOT collide with existing defined commands for that phase),
and random (potentially-empty) metadata and data.

During a phase, the controller MAY send one or more grease stanzas at any point
where they might otherwise send a command. Note that grease cannot replace the
done command, but a phase may otherwise have multiple commands sent even if it
is defined as a single-command phase.

TODO: Errors

and how to handle unknown error types in a phase.

Wrapping with recipient-v1

This state machine wraps a single file key (for a single age-encrypted file) to
multiple recipients and/or identities. It enables amortization of identity-specific
costs (such as requesting a PIN or passphrase from the user) across multiple file
encryptions.

Phase 1 [client, uni-directional]

Three commands are defined for this phase:

  • (add-recipient, RECIPIENT) - specifies a recipient that the client wants to
    wrap all the file keys to.
    • RECIPIENT is the Bech32 encoding of a recipient.
  • (add-identity, IDENTITY) - specifies an identity that the client wants to
    wrap all the file keys to.
    • IDENTITY is the Bech32 encoding of an identity.
  • (wrap-file-key; FILE_KEY) - a file key to be wrapped.

The plugin indexes recipients, identities, and file keys in the order received
(starting from 0). The two may be interleaved by the client, with no semantic
implications. The plugin caches each recipient, identity, and file key internally.

Example phase diagram:

-> add-recipient foo

-> add-identity bar

-> wrap-file-key
Base64(FILE_KEY)
-> add-recipient baz

-> wrap-file-key
Base64(FILE_KEY)
-> done

Phase 2 [plugin, bi-directional]

The following commands and responses are defined for this phase:

  • (msg; MESSAGE) - a message that should be displayed to the user, for
    example to prompt them to interact with a hardware key.
    • Response is (ok) (if the message can be displayed) or (fail) (if, for
      example, there is no UI for displaying messages).
    • The response MAY be sent by the client before the message has been displayed
      to the user.
  • (confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE) - a request for
    confirmation that should be displayed to the user, for example to ask them to
    either plug in a hardware key or skip it.
    • MESSAGE is the request or call-to-action to be displayed to the user.
    • YES_STRING and (optionally) NO_STRING are strings that will be displayed
      on buttons or next to selection options in the user's UI.
    • Response is one of the following:
      • (ok, yes) if the user selected the option marked with YES_STRING.
      • (ok, no) if the user selected the option marked with NO_STRING.
      • (fail) if the confirmation request could not be given to the user (for
        example, if there is no UI for displaying messages).
  • (request-public; MESSAGE) - the plugin requires some public string from the
    user in order to progress.
    • Response is (ok; REQUESTED_PUBLIC) or (fail).
  • (request-secret; MESSAGE) - the plugin requires a secret or PIN from the
    user in order to progress.
    • Response is (ok; REQUESTED_SECRET) or (fail).
  • (recipient-stanza, FILE_INDEX STANZA...) - a stanza containing a correctly-wrapped
    file key to one of the recipients.
    • The stanzas do not need to be sent in any particular order; clients will not
      be mapping the stanzas to specific recipients, and will cache stanzas for
      separate files until the state machine completes.
    • Response is (ok).
  • An error command with three variants:
    • (error, recipient RECIPIENT_INDEX; MESSAGE) - a specific recipient is the
      cause of an error.
      • Response is (ok).
    • (error, identity IDENTITY_INDEX; MESSAGE) - a specific identity is the
      cause of an error.
      • Response is (ok).
    • (error, internal; MESSAGE) - a general error occurred during wrapping.
      • Response is (ok).

Having assembled the full set of recipients and identities that the client wishes
to wrap to, the plugin determines whether it can successfully wrap to all
recipients and identities. The plugin MUST generate an error if one or more
recipients or identities cannot be wrapped to.

The plugin then proceeds to wrap the given file keys to the recipients and
identities. While doing so, the plugin may send commands to request additional
help from the client / user.

The plugin is the controller of this phase; clients should not close the
connection if, for example, a user fails to respond in the way the plugin wants
for a particular request. Instead, the client returns (fail) to indicate this;
the plugin then decides whether this response is fatal.

If any errors occur, the plugin MUST NOT send any stanzas to the client.

Once all file keys have been successfully wrapped to all recipients and identities,
the plugin sends the resulting stanzas to the client. The plugin MUST NOT return
more stanzas per file than the number of recipients and identities.

Example phase diagram:

-> request-secret ...
  < ok\nSECRET
-> request-secret ...
  < fail
-> msg  ...
  < ok
-> recipient-stanza 0 ...
  < ok
-> recipient-stanza 1 ...
  < ok
-> recipient-stanza 0 ...
  < ok
-> recipient-stanza 1 ...
  < ok
-> done

Unwrapping with identity-v1

This state machine unwraps multiple file keys from multiple age-encrypted files.
It enables amortization of identity-specific costs (such as requesting a PIN or
passphrase from the user) across multiple file decryptions.

Phase 1 [client, uni-directional]

Two commands are defined for this phase:

  • (add-identity, IDENTITY) - specifies an identity to be used by the plugin
    for trial-unwrapping.
    • IDENTITY is the Bech32 encoding of an identity.
  • (recipient-stanza, FILE_INDEX STANZA...) - conveys a stanza from an age format
    v1 header.
    • File indices are numeric, ordered, and monotonically increasing. Duplicate
      file indices indicate stanzas that are from the same file header, and wrap
      the same file key.

The plugin indexes identities and stanzas in the order received (starting from
0). The two may be interleaved by the client, but the recipient-stanza file
indices must remain ordered and monotonically increasing.

Unknown stanza types MUST be ignored by the plugin.

Example phase diagram:

-> add-identity foo

-> recipient-stanza 0 ...
-> add-identity bar

-> recipient-stanza 0 ...
-> recipient-stanza 0 ...
-> recipient-stanza 1 ...
-> recipient-stanza 1 ...
-> add-identity baz

-> recipient-stanza 1 ...
-> recipient-stanza 2 ...
-> done

Phase 2 [plugin, bi-directional]

The following commands and responses are defined for this phase:

  • (msg; MESSAGE) - a message that should be displayed to the user, for
    example to prompt them to interact with a hardware key.
    • Response is (ok) (if the message can be displayed) or (fail) (if, for
      example, there is no UI for displaying messages).
    • The response MAY be sent by the client before the message has been displayed
      to the user.
  • (confirm, Base64(YES_STRING) [Base64(NO_STRING)]; MESSAGE) - a request for
    confirmation that should be displayed to the user, for example to ask them to
    either plug in a hardware key or skip it.
    • MESSAGE is the request or call-to-action to be displayed to the user.
    • YES_STRING and (optionally) NO_STRING are strings that will be displayed
      on buttons or next to selection options in the user's UI.
    • Response is one of the following:
      • (ok, yes) if the user selected the option marked with YES_STRING.
      • (ok, no) if the user selected the option marked with NO_STRING.
      • (fail) if the confirmation request could not be given to the user (for
        example, if there is no UI for displaying messages).
  • (request-public; MESSAGE) - the plugin requires some public string from the
    user in order to progress.
    • Response is (ok; REQUESTED_PUBLIC) or (fail).
  • (request-secret; MESSAGE) - the plugin requires a secret or PIN from the
    user in order to progress.
    • Response is (ok; REQUESTED_SECRET) or (fail).
  • (file-key, FILE_INDEX; FILE_KEY) - an unwrapped file key.
    • Response is (ok).
  • An error command with three variants:
    • (error, identity IDENTITY_INDEX; MESSAGE) - an error occured while using
      this identity.
      • Response is (ok).
    • (error, stanza FILE_INDEX STANZA_INDEX; MESSAGE) - an error occured while
      using a specific stanza. This MUST NOT be used for unknown stanzas, only for
      stanzas with an expected tag but that are otherwise invalid (indicating an
      invalid age header).
      • Response is (ok).
    • (error, internal; MESSAGE) - a general error occurred during unwrapping.
      • Response is (ok).

Having assembled the full list of identities to use, and supported stanzas to
trial-unwrap, the plugin enforces structural validity on both:

  • If there are unknown or invalid identity types, the plugin MUST return errors
    and MUST NOT attempt to unwrap any file keys with otherwise-valid identities.
  • If any known stanza is structurally invalid, the plugin MUST return an error
    for that stanza, and MUST NOT unwrap any stanzas with the same FILE_INDEX.
    The plugin MAY continue to unwrap stanzas from other files.

The plugin then chooses internally an order of identities to try, and sends
commands to request additional help from the client / user, or store unwrapped
file keys.

The plugin is the controller of this phase; clients should not close the
connection if, for example, a user fails to respond in the way the plugin wants
for a particular request. Instead, the client returns (fail) to indicate this;
the plugin is expected to try another recipient stanza or identity.

When the plugin is able to determine whether a given file key can be unwrapped
or not, it takes one of three actions:

  • If a stanza cannot be unwrapped that detectably should be unwrappable (e.g.
    the stanza specifically identifies the recipient), the plugin sends
    error stanza.
    • TODO: Should this be a hard error (preventing that file from being unwrapped)?
      Probably yes (we already assume we don't get 32-bit collisions for SSH tags).
  • If any recipient stanza with a given FILE_INDEX can be unwrapped, it sends
    file-key.
  • If all known and valid stanzas for a given file cannot be unwrapped, and none
    are expected to be unwrappable, the plugin does not send anything. That is,
    file keys that cannot be unwrapped are implicit.

These may be interleaved with other requests, and the client must cache the
unwrapped file keys until this phase is complete. The plugin sends no more than
one file key per file index in the original set of stanzas.

  • TODO: Should IPC errors (e.g. invalid command structure or argument types) be
    separated from state machine errors (e.g. invalid identities)?
    • Probably not; it should be possible to run the IPC protocol over e.g. TCP.
  • TODO: What about identities that are not unknown or invalid, but for which we
    get an error while trying to use them? (e.g. YubiKey missing, invalid PIN,
    cloud KMS rejection)

Example phase diagram:

-> request-secret ...
  < ok\nSECRET
-> request-secret ...
  < ok\nSECRET
-> file-key FILE_INDEX ...
  < ok
-> file-key FILE_INDEX ...
  < ok
-> request-secret ...
  < fail
-> msg  ...
  < ok
-> error ...
  < ok
-> file-key FILE_INDEX ...
  < ok
-> file-key FILE_INDEX ...
  < ok
-> msg  ...
  < fail
-> error ...
  < ok
-> file-key FILE_INDEX ...
  < ok
-> done

Example interactions

  • A: age client
  • P: plugin

Key wrapping

A --> P | -> add-recipient RECIPIENT_1
        |
A --> P | -> add-recipient RECIPIENT_2
        |
A --> P | -> wrap-file-key
        | Base64(FILE_KEY)
A --> P | -> done
        |
A <-- P | -> recipient-stanza 0 some-tag CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20
        | C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig
A <-- P | -> recipient-stanza 0 another-tag 42 ytazqsbmUnPwVWMVx0c1X9iUtGdY4yAB08UQTY2hNCI
        | N3pgrXkbIn/RrVt0T0G3sQr1wGWuclqKxTSWHSqGdkc
A <-- P | -> done
        |

Key unwrapping

A --> P | -> add-identity YUBIKEY_ID_PIN_REQUIRED
        |
A --> P | -> add-identity YUBIKEY_ID_NO_PIN
        |
A --> P | -> recipient-stanza 0 yubikey BjH7FA RO+wV4kbbl4NtSmp56lQcfRdRp3dEFpdQmWkaoiw6lY
        | 51eEu5Oo2JYAG7OU4oamH03FDRP18/GnzeCrY7Z+sa8
A --> P | -> recipient-stanza 1 yubikey mhir0Q ZV/AhotwSGqaPCU43cepl4WYUouAa17a3xpu4G2yi5k
        | fgMiVLJHMlg9fW7CVG/hPS5EAU4Zeg19LyCP7SoH5nA
A --> P | -> done
        |
A <-- P | -> msg
        | Base64("Please insert YubiKey with serial 65227134")
A --> P | -> ok
        |
A <-- P | -> file-key 0
        | Base64(FILE_KEY)
A --> P | -> ok
        |
A <-- P | -> request-secret
        | Base64("Please enter PIN for YubiKey with serial 65227134")
A --> P | -> ok
        | Base64(123456)
A <-- P | -> file-key 1
        | Base64(FILE_KEY)
A --> P | -> ok
        |
A <-- P | -> done
        |

Rationale

The two driving goals behind the design are:

  • No configuration.
  • Simplest user experience possible.

In order to have no configuration, age clients need to be able to detect, for
example, which plugins support which recipient types. The simplest way to do
this is to have a 1:1 relationship between plugins and recipient types.

Considered Alternatives

  • An age plugin could be queried for supported recipient types. This was
    discounted because it requires starting every installed plugin when only a
    subset of them might actually be able to encrypt or decrypt a given message.

  • An age plugin could, at install time, store a file containing the recipient
    types that it supports. This was discounted because it requires significantly
    more complex configuration support; instead of only needing one per-user
    folder, we would also need to handle system configuration folders across
    various platforms, as well as be safe across OS upgrades.