c2sp.org/age-plugin
This is the development version of this specification, rendered from the tip of the main branch.

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:

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:

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.

Conventions used in this document §

ABNF syntax follows RFC 5234 and RFC 7405 and references the core rules in RFC 5234, Appendix B.1.

The Base64 encoding used throughout is the standard Base 64 encoding specified in RFC 4648, Section 4, without padding characters. Encoders MUST generate canonical Base64 according to RFC 4648, Section 3.5, and decoders MUST reject non-canonical encodings.

Mapping recipients and identities to plugin binaries §

age plugins are identified by a case-insensitive string NAME.

A NAME MUST NOT be empty and MUST only contain alphanumeric characters and the following special characters: -, _, ., +. In ABNF:

NAME = 1*(ALPHA / DIGIT / "-" / "_" / "." / "+")

This string is used in three places:

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. Paths relative to the current working directory MUST NOT be searched, even on platforms or systems where this is the default. Note that the plugin name MUST NOT contain path separators.

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 and/or mlkem768x25519 recipient stanzas. 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:

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:

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:

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

Stanzas are serialized exactly as in the v1 age header, 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:

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:

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] §

Four commands are defined for this phase:

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:

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.

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:

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:

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

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:

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.

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 §

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
        |
P --> A | -> recipient-stanza 0 some-tag CJM36AHmTbdHSuOQL+NESqyVQE75f2e610iRdLPEN20
        | C3ZAeY64NXS4QFrksLm3EGz+uPRyI0eQsWw7LWbbYig
P --> A | -> recipient-stanza 0 another-tag 42 ytazqsbmUnPwVWMVx0c1X9iUtGdY4yAB08UQTY2hNCI
        | N3pgrXkbIn/RrVt0T0G3sQr1wGWuclqKxTSWHSqGdkc
P --> A | -> 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
        |
P --> A | -> msg
        | Base64("Please insert YubiKey with serial 65227134")
A --> P | -> ok
        |
P --> A | -> file-key 0
        | Base64(FILE_KEY)
A --> P | -> ok
        |
P --> A | -> request-secret
        | Base64("Please enter PIN for YubiKey with serial 65227134")
A --> P | -> ok
        | Base64(123456)
P --> A | -> file-key 1
        | Base64(FILE_KEY)
A --> P | -> ok
        |
P --> A | -> done
        |

Rationale §

The two driving goals behind the design are:

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 §